mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
style: 完成所有文件的lint
This commit is contained in:
@@ -20,4 +20,3 @@
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"hono": "^4.12.15",
|
||||
"jsqr": "^1.4.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from "bun:test";
|
||||
import {
|
||||
describe,
|
||||
test,
|
||||
expect,
|
||||
beforeEach,
|
||||
afterAll,
|
||||
mock,
|
||||
spyOn,
|
||||
} from 'bun:test'
|
||||
|
||||
// Mock config before importing modules that depend on it
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-key-1", "test-key-2"],
|
||||
baseUrl: "",
|
||||
host: '0.0.0.0',
|
||||
apiKeys: ['test-key-1', 'test-key-2'],
|
||||
baseUrl: '',
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
@@ -13,153 +21,157 @@ const mockConfig = {
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
}
|
||||
|
||||
mock.module("../config", () => ({
|
||||
mock.module('../config', () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
getBaseUrl: () => 'http://localhost:3000',
|
||||
}))
|
||||
|
||||
import { validateApiKey, hashApiKey } from "../auth/api-key";
|
||||
import { generateWorkerJwt, verifyWorkerJwt } from "../auth/jwt";
|
||||
import { issueToken, resolveToken } from "../auth/token";
|
||||
import { storeReset, storeCreateUser } from "../store";
|
||||
import { validateApiKey, hashApiKey } from '../auth/api-key'
|
||||
import { generateWorkerJwt, verifyWorkerJwt } from '../auth/jwt'
|
||||
import { issueToken, resolveToken } from '../auth/token'
|
||||
import { storeReset, storeCreateUser } from '../store'
|
||||
|
||||
// ---------- api-key ----------
|
||||
|
||||
describe("validateApiKey", () => {
|
||||
test("validates a configured API key", () => {
|
||||
expect(validateApiKey("test-key-1")).toBe(true);
|
||||
expect(validateApiKey("test-key-2")).toBe(true);
|
||||
});
|
||||
describe('validateApiKey', () => {
|
||||
test('validates a configured API key', () => {
|
||||
expect(validateApiKey('test-key-1')).toBe(true)
|
||||
expect(validateApiKey('test-key-2')).toBe(true)
|
||||
})
|
||||
|
||||
test("rejects unknown key", () => {
|
||||
expect(validateApiKey("unknown-key")).toBe(false);
|
||||
});
|
||||
test('rejects unknown key', () => {
|
||||
expect(validateApiKey('unknown-key')).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects undefined", () => {
|
||||
expect(validateApiKey(undefined)).toBe(false);
|
||||
});
|
||||
test('rejects undefined', () => {
|
||||
expect(validateApiKey(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("rejects empty string", () => {
|
||||
expect(validateApiKey("")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('rejects empty string', () => {
|
||||
expect(validateApiKey('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hashApiKey", () => {
|
||||
test("produces consistent SHA-256 hex", () => {
|
||||
const hash = hashApiKey("my-key");
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(hashApiKey("my-key")).toBe(hash);
|
||||
});
|
||||
describe('hashApiKey', () => {
|
||||
test('produces consistent SHA-256 hex', () => {
|
||||
const hash = hashApiKey('my-key')
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(hashApiKey('my-key')).toBe(hash)
|
||||
})
|
||||
|
||||
test("different keys produce different hashes", () => {
|
||||
expect(hashApiKey("key-a")).not.toBe(hashApiKey("key-b"));
|
||||
});
|
||||
});
|
||||
test('different keys produce different hashes', () => {
|
||||
expect(hashApiKey('key-a')).not.toBe(hashApiKey('key-b'))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- jwt ----------
|
||||
|
||||
describe("JWT", () => {
|
||||
describe('JWT', () => {
|
||||
// JWT reads process.env.RCS_API_KEYS directly (not via config)
|
||||
const originalKeys = process.env.RCS_API_KEYS;
|
||||
const originalKeys = process.env.RCS_API_KEYS
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||
});
|
||||
process.env.RCS_API_KEYS = 'jwt-test-secret'
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
process.env.RCS_API_KEYS = originalKeys;
|
||||
});
|
||||
process.env.RCS_API_KEYS = originalKeys
|
||||
})
|
||||
|
||||
describe("generateWorkerJwt", () => {
|
||||
test("produces a three-part base64url token", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
const parts = token.split(".");
|
||||
expect(parts).toHaveLength(3);
|
||||
describe('generateWorkerJwt', () => {
|
||||
test('produces a three-part base64url token', () => {
|
||||
const token = generateWorkerJwt('ses_123', 3600)
|
||||
const parts = token.split('.')
|
||||
expect(parts).toHaveLength(3)
|
||||
for (const part of parts) {
|
||||
expect(part).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(part).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("contains correct header", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
const header = JSON.parse(atob(token.split(".")[0].replace(/-/g, "+").replace(/_/g, "/")));
|
||||
expect(header.alg).toBe("HS256");
|
||||
expect(header.typ).toBe("JWT");
|
||||
});
|
||||
test('contains correct header', () => {
|
||||
const token = generateWorkerJwt('ses_123', 3600)
|
||||
const header = JSON.parse(
|
||||
atob(token.split('.')[0].replace(/-/g, '+').replace(/_/g, '/')),
|
||||
)
|
||||
expect(header.alg).toBe('HS256')
|
||||
expect(header.typ).toBe('JWT')
|
||||
})
|
||||
|
||||
test("throws when no API key configured", () => {
|
||||
delete process.env.RCS_API_KEYS;
|
||||
expect(() => generateWorkerJwt("ses_123", 3600)).toThrow("No API key configured");
|
||||
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||
});
|
||||
});
|
||||
test('throws when no API key configured', () => {
|
||||
delete process.env.RCS_API_KEYS
|
||||
expect(() => generateWorkerJwt('ses_123', 3600)).toThrow(
|
||||
'No API key configured',
|
||||
)
|
||||
process.env.RCS_API_KEYS = 'jwt-test-secret'
|
||||
})
|
||||
})
|
||||
|
||||
describe("verifyWorkerJwt", () => {
|
||||
test("verifies a valid token", () => {
|
||||
const token = generateWorkerJwt("ses_abc", 3600);
|
||||
const payload = verifyWorkerJwt(token);
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload!.session_id).toBe("ses_abc");
|
||||
expect(payload!.role).toBe("worker");
|
||||
expect(payload!.iat).toBeGreaterThan(0);
|
||||
expect(payload!.exp).toBeGreaterThan(payload!.iat);
|
||||
});
|
||||
describe('verifyWorkerJwt', () => {
|
||||
test('verifies a valid token', () => {
|
||||
const token = generateWorkerJwt('ses_abc', 3600)
|
||||
const payload = verifyWorkerJwt(token)
|
||||
expect(payload).not.toBeNull()
|
||||
expect(payload!.session_id).toBe('ses_abc')
|
||||
expect(payload!.role).toBe('worker')
|
||||
expect(payload!.iat).toBeGreaterThan(0)
|
||||
expect(payload!.exp).toBeGreaterThan(payload!.iat)
|
||||
})
|
||||
|
||||
test("returns null for expired token", () => {
|
||||
const token = generateWorkerJwt("ses_old", -10);
|
||||
expect(verifyWorkerJwt(token)).toBeNull();
|
||||
});
|
||||
test('returns null for expired token', () => {
|
||||
const token = generateWorkerJwt('ses_old', -10)
|
||||
expect(verifyWorkerJwt(token)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for malformed token (not 3 parts)", () => {
|
||||
expect(verifyWorkerJwt("a.b")).toBeNull();
|
||||
expect(verifyWorkerJwt("just-a-string")).toBeNull();
|
||||
});
|
||||
test('returns null for malformed token (not 3 parts)', () => {
|
||||
expect(verifyWorkerJwt('a.b')).toBeNull()
|
||||
expect(verifyWorkerJwt('just-a-string')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for tampered signature", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
const parts = token.split(".");
|
||||
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`;
|
||||
expect(verifyWorkerJwt(tampered)).toBeNull();
|
||||
});
|
||||
test('returns null for tampered signature', () => {
|
||||
const token = generateWorkerJwt('ses_123', 3600)
|
||||
const parts = token.split('.')
|
||||
const tampered = `${parts[0]}.${parts[1]}.${parts[2].slice(0, -4)}xxxx`
|
||||
expect(verifyWorkerJwt(tampered)).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for wrong signing key", () => {
|
||||
const token = generateWorkerJwt("ses_123", 3600);
|
||||
process.env.RCS_API_KEYS = "wrong-key";
|
||||
expect(verifyWorkerJwt(token)).toBeNull();
|
||||
process.env.RCS_API_KEYS = "jwt-test-secret";
|
||||
});
|
||||
});
|
||||
});
|
||||
test('returns null for wrong signing key', () => {
|
||||
const token = generateWorkerJwt('ses_123', 3600)
|
||||
process.env.RCS_API_KEYS = 'wrong-key'
|
||||
expect(verifyWorkerJwt(token)).toBeNull()
|
||||
process.env.RCS_API_KEYS = 'jwt-test-secret'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- token ----------
|
||||
|
||||
describe("issueToken / resolveToken", () => {
|
||||
describe('issueToken / resolveToken', () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
});
|
||||
storeReset()
|
||||
})
|
||||
|
||||
test("issues and resolves a token", () => {
|
||||
storeCreateUser("alice");
|
||||
const { token, expires_in } = issueToken("alice");
|
||||
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/);
|
||||
expect(expires_in).toBe(86400);
|
||||
expect(resolveToken(token)).toBe("alice");
|
||||
});
|
||||
test('issues and resolves a token', () => {
|
||||
storeCreateUser('alice')
|
||||
const { token, expires_in } = issueToken('alice')
|
||||
expect(token).toMatch(/^rct_\d+_[0-9a-f]+$/)
|
||||
expect(expires_in).toBe(86400)
|
||||
expect(resolveToken(token)).toBe('alice')
|
||||
})
|
||||
|
||||
test("returns null for unknown token", () => {
|
||||
expect(resolveToken("nonexistent")).toBeNull();
|
||||
});
|
||||
test('returns null for unknown token', () => {
|
||||
expect(resolveToken('nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null for undefined token", () => {
|
||||
expect(resolveToken(undefined)).toBeNull();
|
||||
});
|
||||
test('returns null for undefined token', () => {
|
||||
expect(resolveToken(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
test("tokens are unique", () => {
|
||||
storeCreateUser("alice");
|
||||
const t1 = issueToken("alice").token;
|
||||
const t2 = issueToken("alice").token;
|
||||
expect(t1).not.toBe(t2);
|
||||
});
|
||||
});
|
||||
test('tokens are unique', () => {
|
||||
storeCreateUser('alice')
|
||||
const t1 = issueToken('alice').token
|
||||
const t2 = issueToken('alice').token
|
||||
expect(t1).not.toBe(t2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,182 +1,225 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import {
|
||||
getAutomationStateSnapshot,
|
||||
getAutomationStateEventPayload,
|
||||
automationStatesEqual,
|
||||
} from "../services/automationState";
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
} from '../services/automationState'
|
||||
import type { AutomationStateResponse } from '../types/api'
|
||||
|
||||
// =============================================================================
|
||||
// normalizeAutomationState (via getAutomationStateSnapshot)
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizeAutomationState", () => {
|
||||
test("returns undefined when metadata has no automation_state key", () => {
|
||||
expect(getAutomationStateSnapshot({})).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot({ other: true })).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot(null)).toBeUndefined();
|
||||
expect(getAutomationStateSnapshot(undefined)).toBeUndefined();
|
||||
});
|
||||
describe('normalizeAutomationState', () => {
|
||||
test('returns undefined when metadata has no automation_state key', () => {
|
||||
expect(getAutomationStateSnapshot({})).toBeUndefined()
|
||||
expect(getAutomationStateSnapshot({ other: true })).toBeUndefined()
|
||||
expect(getAutomationStateSnapshot(null)).toBeUndefined()
|
||||
expect(getAutomationStateSnapshot(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns disabled state for null automation_state", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: null });
|
||||
test('returns disabled state for null automation_state', () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: null })
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("returns disabled state for non-object automation_state", () => {
|
||||
for (const val of ["string", 123, true, []]) {
|
||||
const result = getAutomationStateSnapshot({ automation_state: val });
|
||||
expect(result?.enabled).toBe(false);
|
||||
test('returns disabled state for non-object automation_state', () => {
|
||||
for (const val of ['string', 123, true, []]) {
|
||||
const result = getAutomationStateSnapshot({ automation_state: val })
|
||||
expect(result?.enabled).toBe(false)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("normalizes enabled: true correctly", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true } });
|
||||
expect(result?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
test("normalizes enabled to false for non-true values", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: "yes" } });
|
||||
expect(result?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts phase: standby", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "standby" } });
|
||||
expect(result?.phase).toBe("standby");
|
||||
});
|
||||
|
||||
test("accepts phase: sleeping", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase: "sleeping" } });
|
||||
expect(result?.phase).toBe("sleeping");
|
||||
});
|
||||
|
||||
test("rejects invalid phase values", () => {
|
||||
for (const phase of ["running", "idle", "active", "", null]) {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, phase } });
|
||||
expect(result?.phase).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("normalizes next_tick_at as number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: 12345 } });
|
||||
expect(result?.next_tick_at).toBe(12345);
|
||||
});
|
||||
|
||||
test("normalizes next_tick_at as null for non-number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, next_tick_at: "soon" } });
|
||||
expect(result?.next_tick_at).toBeNull();
|
||||
});
|
||||
|
||||
test("normalizes sleep_until as number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: 99999 } });
|
||||
expect(result?.sleep_until).toBe(99999);
|
||||
});
|
||||
|
||||
test("normalizes sleep_until as null for non-number", () => {
|
||||
const result = getAutomationStateSnapshot({ automation_state: { enabled: true, sleep_until: false } });
|
||||
expect(result?.sleep_until).toBeNull();
|
||||
});
|
||||
|
||||
test("fully normalizes a complete valid state", () => {
|
||||
test('normalizes enabled: true correctly', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, phase: "sleeping", next_tick_at: 100, sleep_until: 200 },
|
||||
});
|
||||
automation_state: { enabled: true },
|
||||
})
|
||||
expect(result?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
test('normalizes enabled to false for non-true values', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: 'yes' },
|
||||
})
|
||||
expect(result?.enabled).toBe(false)
|
||||
})
|
||||
|
||||
test('accepts phase: standby', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, phase: 'standby' },
|
||||
})
|
||||
expect(result?.phase).toBe('standby')
|
||||
})
|
||||
|
||||
test('accepts phase: sleeping', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, phase: 'sleeping' },
|
||||
})
|
||||
expect(result?.phase).toBe('sleeping')
|
||||
})
|
||||
|
||||
test('rejects invalid phase values', () => {
|
||||
for (const phase of ['running', 'idle', 'active', '', null]) {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, phase },
|
||||
})
|
||||
expect(result?.phase).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
test('normalizes next_tick_at as number', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, next_tick_at: 12345 },
|
||||
})
|
||||
expect(result?.next_tick_at).toBe(12345)
|
||||
})
|
||||
|
||||
test('normalizes next_tick_at as null for non-number', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, next_tick_at: 'soon' },
|
||||
})
|
||||
expect(result?.next_tick_at).toBeNull()
|
||||
})
|
||||
|
||||
test('normalizes sleep_until as number', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, sleep_until: 99999 },
|
||||
})
|
||||
expect(result?.sleep_until).toBe(99999)
|
||||
})
|
||||
|
||||
test('normalizes sleep_until as null for non-number', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: { enabled: true, sleep_until: false },
|
||||
})
|
||||
expect(result?.sleep_until).toBeNull()
|
||||
})
|
||||
|
||||
test('fully normalizes a complete valid state', () => {
|
||||
const result = getAutomationStateSnapshot({
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'sleeping',
|
||||
next_tick_at: 100,
|
||||
sleep_until: 200,
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
phase: "sleeping",
|
||||
phase: 'sleeping',
|
||||
next_tick_at: 100,
|
||||
sleep_until: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// getAutomationStateEventPayload
|
||||
// =============================================================================
|
||||
|
||||
describe("getAutomationStateEventPayload", () => {
|
||||
test("returns disabled default when no automation_state in metadata", () => {
|
||||
const result = getAutomationStateEventPayload({});
|
||||
describe('getAutomationStateEventPayload', () => {
|
||||
test('returns disabled default when no automation_state in metadata', () => {
|
||||
const result = getAutomationStateEventPayload({})
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("returns disabled default for null metadata", () => {
|
||||
const result = getAutomationStateEventPayload(null);
|
||||
test('returns disabled default for null metadata', () => {
|
||||
const result = getAutomationStateEventPayload(null)
|
||||
expect(result).toEqual({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("returns normalized state when automation_state present", () => {
|
||||
test('returns normalized state when automation_state present', () => {
|
||||
const result = getAutomationStateEventPayload({
|
||||
automation_state: { enabled: true, phase: "standby", next_tick_at: 50, sleep_until: 60 },
|
||||
});
|
||||
automation_state: {
|
||||
enabled: true,
|
||||
phase: 'standby',
|
||||
next_tick_at: 50,
|
||||
sleep_until: 60,
|
||||
},
|
||||
})
|
||||
expect(result).toEqual({
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
phase: 'standby',
|
||||
next_tick_at: 50,
|
||||
sleep_until: 60,
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
|
||||
test("returns a new object each call (not frozen reference)", () => {
|
||||
const a = getAutomationStateEventPayload({});
|
||||
const b = getAutomationStateEventPayload({});
|
||||
expect(a).toEqual(b);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
test('returns a new object each call (not frozen reference)', () => {
|
||||
const a = getAutomationStateEventPayload({})
|
||||
const b = getAutomationStateEventPayload({})
|
||||
expect(a).toEqual(b)
|
||||
expect(a).not.toBe(b)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// automationStatesEqual
|
||||
// =============================================================================
|
||||
|
||||
describe("automationStatesEqual", () => {
|
||||
describe('automationStatesEqual', () => {
|
||||
const base: AutomationStateResponse = {
|
||||
enabled: true,
|
||||
phase: "standby",
|
||||
phase: 'standby',
|
||||
next_tick_at: 100,
|
||||
sleep_until: 200,
|
||||
};
|
||||
}
|
||||
|
||||
test("returns true for identical states", () => {
|
||||
expect(automationStatesEqual(base, { ...base })).toBe(true);
|
||||
});
|
||||
test('returns true for identical states', () => {
|
||||
expect(automationStatesEqual(base, { ...base })).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when enabled differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false);
|
||||
});
|
||||
test('returns false when enabled differs', () => {
|
||||
expect(automationStatesEqual(base, { ...base, enabled: false })).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when phase differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, phase: "sleeping" })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false);
|
||||
});
|
||||
test('returns false when phase differs', () => {
|
||||
expect(automationStatesEqual(base, { ...base, phase: 'sleeping' })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(automationStatesEqual(base, { ...base, phase: null })).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false when next_tick_at differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(false);
|
||||
});
|
||||
test('returns false when next_tick_at differs', () => {
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: 999 })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(automationStatesEqual(base, { ...base, next_tick_at: null })).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("returns false when sleep_until differs", () => {
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(false);
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(false);
|
||||
});
|
||||
test('returns false when sleep_until differs', () => {
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: 999 })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(automationStatesEqual(base, { ...base, sleep_until: null })).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test("returns true when both are disabled defaults", () => {
|
||||
const disabled: AutomationStateResponse = { enabled: false, phase: null, next_tick_at: null, sleep_until: null };
|
||||
expect(automationStatesEqual(disabled, { ...disabled })).toBe(true);
|
||||
});
|
||||
});
|
||||
test('returns true when both are disabled defaults', () => {
|
||||
const disabled: AutomationStateResponse = {
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
}
|
||||
expect(automationStatesEqual(disabled, { ...disabled })).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,256 +1,276 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { toClientPayload } from "../transport/client-payload";
|
||||
import type { SessionEvent } from "../transport/event-bus";
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { toClientPayload } from '../transport/client-payload'
|
||||
import type { SessionEvent } from '../transport/event-bus'
|
||||
|
||||
function makeEvent(overrides: Partial<SessionEvent> & Pick<SessionEvent, "type" | "sessionId">): SessionEvent {
|
||||
function makeEvent(
|
||||
overrides: Partial<SessionEvent> & Pick<SessionEvent, 'type' | 'sessionId'>,
|
||||
): SessionEvent {
|
||||
return {
|
||||
id: "evt-1",
|
||||
id: 'evt-1',
|
||||
payload: null,
|
||||
direction: "inbound",
|
||||
direction: 'inbound',
|
||||
seqNum: 1,
|
||||
createdAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// user / user_message
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — user message", () => {
|
||||
test("maps user type with content", () => {
|
||||
describe('toClientPayload — user message', () => {
|
||||
test('maps user type with content', () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-1",
|
||||
payload: { content: "hello" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("user");
|
||||
expect(result.session_id).toBe("sess-1");
|
||||
expect((result as any).message.role).toBe("user");
|
||||
expect((result as any).message.content).toBe("hello");
|
||||
});
|
||||
type: 'user',
|
||||
sessionId: 'sess-1',
|
||||
payload: { content: 'hello' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('user')
|
||||
expect(result.session_id).toBe('sess-1')
|
||||
expect((result as any).message.role).toBe('user')
|
||||
expect((result as any).message.content).toBe('hello')
|
||||
})
|
||||
|
||||
test("maps user_message type same as user", () => {
|
||||
test('maps user_message type same as user', () => {
|
||||
const event = makeEvent({
|
||||
type: "user_message",
|
||||
sessionId: "sess-2",
|
||||
payload: { content: "world" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("user");
|
||||
expect(result.session_id).toBe("sess-2");
|
||||
});
|
||||
type: 'user_message',
|
||||
sessionId: 'sess-2',
|
||||
payload: { content: 'world' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('user')
|
||||
expect(result.session_id).toBe('sess-2')
|
||||
})
|
||||
|
||||
test("falls back to message field when content is missing", () => {
|
||||
test('falls back to message field when content is missing', () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-3",
|
||||
payload: { message: "fallback msg" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).message.content).toBe("fallback msg");
|
||||
});
|
||||
type: 'user',
|
||||
sessionId: 'sess-3',
|
||||
payload: { message: 'fallback msg' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).message.content).toBe('fallback msg')
|
||||
})
|
||||
|
||||
test("falls back to empty string when both content and message missing", () => {
|
||||
test('falls back to empty string when both content and message missing', () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-4",
|
||||
type: 'user',
|
||||
sessionId: 'sess-4',
|
||||
payload: {},
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).message.content).toBe("");
|
||||
});
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).message.content).toBe('')
|
||||
})
|
||||
|
||||
test("includes isSynthetic when true", () => {
|
||||
test('includes isSynthetic when true', () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-5",
|
||||
payload: { content: "auto", isSynthetic: true },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).isSynthetic).toBe(true);
|
||||
});
|
||||
type: 'user',
|
||||
sessionId: 'sess-5',
|
||||
payload: { content: 'auto', isSynthetic: true },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).isSynthetic).toBe(true)
|
||||
})
|
||||
|
||||
test("does not include isSynthetic when false", () => {
|
||||
test('does not include isSynthetic when false', () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-6",
|
||||
payload: { content: "manual", isSynthetic: false },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).isSynthetic).toBeUndefined();
|
||||
});
|
||||
type: 'user',
|
||||
sessionId: 'sess-6',
|
||||
payload: { content: 'manual', isSynthetic: false },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).isSynthetic).toBeUndefined()
|
||||
})
|
||||
|
||||
test("uses payload.uuid when present", () => {
|
||||
test('uses payload.uuid when present', () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-7",
|
||||
payload: { content: "hi", uuid: "custom-uuid" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.uuid).toBe("custom-uuid");
|
||||
});
|
||||
type: 'user',
|
||||
sessionId: 'sess-7',
|
||||
payload: { content: 'hi', uuid: 'custom-uuid' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.uuid).toBe('custom-uuid')
|
||||
})
|
||||
|
||||
test("falls back to event.id when payload.uuid is missing", () => {
|
||||
test('falls back to event.id when payload.uuid is missing', () => {
|
||||
const event = makeEvent({
|
||||
type: "user",
|
||||
sessionId: "sess-8",
|
||||
payload: { content: "hi" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.uuid).toBe("evt-1");
|
||||
});
|
||||
});
|
||||
type: 'user',
|
||||
sessionId: 'sess-8',
|
||||
payload: { content: 'hi' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.uuid).toBe('evt-1')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// permission_response / control_response
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — permission response", () => {
|
||||
test("approved=true maps to allow behavior", () => {
|
||||
describe('toClientPayload — permission response', () => {
|
||||
test('approved=true maps to allow behavior', () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-1",
|
||||
payload: { approved: true, request_id: "req-1" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
const resp = (result as any).response;
|
||||
expect(resp.subtype).toBe("success");
|
||||
expect(resp.request_id).toBe("req-1");
|
||||
expect(resp.response.behavior).toBe("allow");
|
||||
});
|
||||
type: 'permission_response',
|
||||
sessionId: 'sess-1',
|
||||
payload: { approved: true, request_id: 'req-1' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('control_response')
|
||||
const resp = (result as any).response
|
||||
expect(resp.subtype).toBe('success')
|
||||
expect(resp.request_id).toBe('req-1')
|
||||
expect(resp.response.behavior).toBe('allow')
|
||||
})
|
||||
|
||||
test("approved=false maps to deny behavior with error", () => {
|
||||
test('approved=false maps to deny behavior with error', () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-2",
|
||||
payload: { approved: false, request_id: "req-2" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
const resp = (result as any).response;
|
||||
expect(resp.subtype).toBe("error");
|
||||
expect(resp.error).toBe("Permission denied by user");
|
||||
expect(resp.response.behavior).toBe("deny");
|
||||
});
|
||||
type: 'permission_response',
|
||||
sessionId: 'sess-2',
|
||||
payload: { approved: false, request_id: 'req-2' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('control_response')
|
||||
const resp = (result as any).response
|
||||
expect(resp.subtype).toBe('error')
|
||||
expect(resp.error).toBe('Permission denied by user')
|
||||
expect(resp.response.behavior).toBe('deny')
|
||||
})
|
||||
|
||||
test("approved=false includes feedback message when provided", () => {
|
||||
test('approved=false includes feedback message when provided', () => {
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-3",
|
||||
payload: { approved: false, request_id: "req-3", message: "please revise" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.message).toBe("please revise");
|
||||
});
|
||||
type: 'permission_response',
|
||||
sessionId: 'sess-3',
|
||||
payload: {
|
||||
approved: false,
|
||||
request_id: 'req-3',
|
||||
message: 'please revise',
|
||||
},
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).response.message).toBe('please revise')
|
||||
})
|
||||
|
||||
test("passes through existingResponse directly", () => {
|
||||
const existingResponse = { subtype: "success", custom: true };
|
||||
test('passes through existingResponse directly', () => {
|
||||
const existingResponse = { subtype: 'success', custom: true }
|
||||
const event = makeEvent({
|
||||
type: "control_response",
|
||||
sessionId: "sess-4",
|
||||
type: 'control_response',
|
||||
sessionId: 'sess-4',
|
||||
payload: { approved: true, response: existingResponse },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_response");
|
||||
expect((result as any).response).toBe(existingResponse);
|
||||
});
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('control_response')
|
||||
expect((result as any).response).toBe(existingResponse)
|
||||
})
|
||||
|
||||
test("includes updatedInput when approved with updated_input", () => {
|
||||
const updatedInput = { file_path: "/new/path" };
|
||||
test('includes updatedInput when approved with updated_input', () => {
|
||||
const updatedInput = { file_path: '/new/path' }
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-5",
|
||||
payload: { approved: true, request_id: "req-5", updated_input: updatedInput },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.response.updatedInput).toEqual(updatedInput);
|
||||
});
|
||||
type: 'permission_response',
|
||||
sessionId: 'sess-5',
|
||||
payload: {
|
||||
approved: true,
|
||||
request_id: 'req-5',
|
||||
updated_input: updatedInput,
|
||||
},
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).response.response.updatedInput).toEqual(updatedInput)
|
||||
})
|
||||
|
||||
test("includes updatedPermissions when approved with updated_permissions", () => {
|
||||
const perms = [{ type: "allow", tool: "bash" }];
|
||||
test('includes updatedPermissions when approved with updated_permissions', () => {
|
||||
const perms = [{ type: 'allow', tool: 'bash' }]
|
||||
const event = makeEvent({
|
||||
type: "permission_response",
|
||||
sessionId: "sess-6",
|
||||
payload: { approved: true, request_id: "req-6", updated_permissions: perms },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).response.response.updatedPermissions).toEqual(perms);
|
||||
});
|
||||
});
|
||||
type: 'permission_response',
|
||||
sessionId: 'sess-6',
|
||||
payload: {
|
||||
approved: true,
|
||||
request_id: 'req-6',
|
||||
updated_permissions: perms,
|
||||
},
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).response.response.updatedPermissions).toEqual(perms)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// interrupt
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — interrupt", () => {
|
||||
test("maps interrupt to control_request with subtype interrupt", () => {
|
||||
describe('toClientPayload — interrupt', () => {
|
||||
test('maps interrupt to control_request with subtype interrupt', () => {
|
||||
const event = makeEvent({
|
||||
type: "interrupt",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_request");
|
||||
expect((result as any).request_id).toBe("evt-1");
|
||||
expect((result as any).request.subtype).toBe("interrupt");
|
||||
});
|
||||
});
|
||||
type: 'interrupt',
|
||||
sessionId: 'sess-1',
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('control_request')
|
||||
expect((result as any).request_id).toBe('evt-1')
|
||||
expect((result as any).request.subtype).toBe('interrupt')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// control_request
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — control_request", () => {
|
||||
test("passes through request_id and request from payload", () => {
|
||||
describe('toClientPayload — control_request', () => {
|
||||
test('passes through request_id and request from payload', () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-1",
|
||||
payload: { request_id: "req-99", request: { subtype: "permission", tool: "bash" } },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("control_request");
|
||||
expect((result as any).request_id).toBe("req-99");
|
||||
expect((result as any).request.subtype).toBe("permission");
|
||||
});
|
||||
type: 'control_request',
|
||||
sessionId: 'sess-1',
|
||||
payload: {
|
||||
request_id: 'req-99',
|
||||
request: { subtype: 'permission', tool: 'bash' },
|
||||
},
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('control_request')
|
||||
expect((result as any).request_id).toBe('req-99')
|
||||
expect((result as any).request.subtype).toBe('permission')
|
||||
})
|
||||
|
||||
test("falls back request to payload when no request field", () => {
|
||||
test('falls back request to payload when no request field', () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-2",
|
||||
payload: { request_id: "req-10", custom: "data" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).request).toEqual({ request_id: "req-10", custom: "data" });
|
||||
});
|
||||
type: 'control_request',
|
||||
sessionId: 'sess-2',
|
||||
payload: { request_id: 'req-10', custom: 'data' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).request).toEqual({
|
||||
request_id: 'req-10',
|
||||
custom: 'data',
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back request_id to event.id when missing", () => {
|
||||
test('falls back request_id to event.id when missing', () => {
|
||||
const event = makeEvent({
|
||||
type: "control_request",
|
||||
sessionId: "sess-3",
|
||||
payload: { request: { subtype: "test" } },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect((result as any).request_id).toBe("evt-1");
|
||||
});
|
||||
});
|
||||
type: 'control_request',
|
||||
sessionId: 'sess-3',
|
||||
payload: { request: { subtype: 'test' } },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect((result as any).request_id).toBe('evt-1')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// default fallback
|
||||
// =============================================================================
|
||||
|
||||
describe("toClientPayload — default types", () => {
|
||||
test("passes through unknown type with type/uuid/session_id/message", () => {
|
||||
describe('toClientPayload — default types', () => {
|
||||
test('passes through unknown type with type/uuid/session_id/message', () => {
|
||||
const event = makeEvent({
|
||||
type: "assistant",
|
||||
sessionId: "sess-1",
|
||||
payload: { uuid: "u-1", content: "response text" },
|
||||
});
|
||||
const result = toClientPayload(event);
|
||||
expect(result.type).toBe("assistant");
|
||||
expect(result.uuid).toBe("u-1");
|
||||
expect(result.session_id).toBe("sess-1");
|
||||
expect(result.message).toEqual({ uuid: "u-1", content: "response text" });
|
||||
});
|
||||
});
|
||||
type: 'assistant',
|
||||
sessionId: 'sess-1',
|
||||
payload: { uuid: 'u-1', content: 'response text' },
|
||||
})
|
||||
const result = toClientPayload(event)
|
||||
expect(result.type).toBe('assistant')
|
||||
expect(result.uuid).toBe('u-1')
|
||||
expect(result.session_id).toBe('sess-1')
|
||||
expect(result.message).toEqual({ uuid: 'u-1', content: 'response text' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
||||
|
||||
// Mock config with very short timeout for testing
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
host: '0.0.0.0',
|
||||
apiKeys: ['test-api-key'],
|
||||
baseUrl: 'http://localhost:3000',
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
@@ -13,12 +13,12 @@ const mockConfig = {
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
}
|
||||
|
||||
mock.module("../config", () => ({
|
||||
mock.module('../config', () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
getBaseUrl: () => 'http://localhost:3000',
|
||||
}))
|
||||
|
||||
import {
|
||||
storeReset,
|
||||
@@ -28,84 +28,91 @@ import {
|
||||
storeUpdateSession,
|
||||
storeGetEnvironment,
|
||||
storeGetSession,
|
||||
} from "../store";
|
||||
import { getEventBus, getAllEventBuses, removeEventBus } from "../transport/event-bus";
|
||||
import { runDisconnectMonitorSweep } from "../services/disconnect-monitor";
|
||||
} from '../store'
|
||||
import {
|
||||
getEventBus,
|
||||
getAllEventBuses,
|
||||
removeEventBus,
|
||||
} from '../transport/event-bus'
|
||||
import { runDisconnectMonitorSweep } from '../services/disconnect-monitor'
|
||||
|
||||
describe("Disconnect Monitor Logic", () => {
|
||||
describe('Disconnect Monitor Logic', () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
storeReset()
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
removeEventBus(key)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("environment times out when lastPollAt is too old", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const timeoutMs = 300 * 1000; // 5 minutes
|
||||
test('environment times out when lastPollAt is too old', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
const timeoutMs = 300 * 1000 // 5 minutes
|
||||
|
||||
// Simulate lastPollAt being 6 minutes ago
|
||||
const oldDate = new Date(Date.now() - timeoutMs - 60000);
|
||||
storeUpdateEnvironment(env.id, { lastPollAt: oldDate });
|
||||
const oldDate = new Date(Date.now() - timeoutMs - 60000)
|
||||
storeUpdateEnvironment(env.id, { lastPollAt: oldDate })
|
||||
|
||||
runDisconnectMonitorSweep();
|
||||
runDisconnectMonitorSweep()
|
||||
|
||||
const updated = storeGetEnvironment(env.id);
|
||||
expect(updated?.status).toBe("disconnected");
|
||||
});
|
||||
const updated = storeGetEnvironment(env.id)
|
||||
expect(updated?.status).toBe('disconnected')
|
||||
})
|
||||
|
||||
test("environment stays active when lastPollAt is recent", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
runDisconnectMonitorSweep();
|
||||
test('environment stays active when lastPollAt is recent', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
runDisconnectMonitorSweep()
|
||||
|
||||
const updated = storeGetEnvironment(env.id);
|
||||
expect(updated?.status).toBe("active");
|
||||
});
|
||||
const updated = storeGetEnvironment(env.id)
|
||||
expect(updated?.status).toBe('active')
|
||||
})
|
||||
|
||||
test("session becomes inactive when updatedAt is too old", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeUpdateSession(session.id, { status: "running" });
|
||||
const rec = storeGetSession(session.id);
|
||||
expect(rec).toBeTruthy();
|
||||
if (!rec) return;
|
||||
test('session becomes inactive when updatedAt is too old', () => {
|
||||
const session = storeCreateSession({})
|
||||
storeUpdateSession(session.id, { status: 'running' })
|
||||
const rec = storeGetSession(session.id)
|
||||
expect(rec).toBeTruthy()
|
||||
if (!rec) return
|
||||
|
||||
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
|
||||
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000)
|
||||
|
||||
runDisconnectMonitorSweep();
|
||||
runDisconnectMonitorSweep()
|
||||
|
||||
const updated = storeGetSession(session.id);
|
||||
expect(updated?.status).toBe("inactive");
|
||||
});
|
||||
const updated = storeGetSession(session.id)
|
||||
expect(updated?.status).toBe('inactive')
|
||||
})
|
||||
|
||||
test("session stays running when recently updated", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeUpdateSession(session.id, { status: "running" });
|
||||
test('session stays running when recently updated', () => {
|
||||
const session = storeCreateSession({})
|
||||
storeUpdateSession(session.id, { status: 'running' })
|
||||
|
||||
runDisconnectMonitorSweep();
|
||||
runDisconnectMonitorSweep()
|
||||
|
||||
const updated = storeGetSession(session.id);
|
||||
expect(updated?.status).toBe("running");
|
||||
});
|
||||
const updated = storeGetSession(session.id)
|
||||
expect(updated?.status).toBe('running')
|
||||
})
|
||||
|
||||
test("session timeout publishes an inactive session_status event", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeUpdateSession(session.id, { status: "idle" });
|
||||
const rec = storeGetSession(session.id);
|
||||
expect(rec).toBeTruthy();
|
||||
if (!rec) return;
|
||||
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000);
|
||||
test('session timeout publishes an inactive session_status event', () => {
|
||||
const session = storeCreateSession({})
|
||||
storeUpdateSession(session.id, { status: 'idle' })
|
||||
const rec = storeGetSession(session.id)
|
||||
expect(rec).toBeTruthy()
|
||||
if (!rec) return
|
||||
rec.updatedAt = new Date(Date.now() - 300 * 1000 * 2 - 60000)
|
||||
|
||||
const bus = getEventBus(session.id);
|
||||
const events: Array<{ type: string; payload: { status?: string } }> = [];
|
||||
bus.subscribe((event) => {
|
||||
events.push({ type: event.type, payload: event.payload as { status?: string } });
|
||||
});
|
||||
const bus = getEventBus(session.id)
|
||||
const events: Array<{ type: string; payload: { status?: string } }> = []
|
||||
bus.subscribe(event => {
|
||||
events.push({
|
||||
type: event.type,
|
||||
payload: event.payload as { status?: string },
|
||||
})
|
||||
})
|
||||
|
||||
runDisconnectMonitorSweep();
|
||||
runDisconnectMonitorSweep()
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "session_status",
|
||||
payload: { status: "inactive" },
|
||||
});
|
||||
});
|
||||
});
|
||||
type: 'session_status',
|
||||
payload: { status: 'inactive' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,176 +1,293 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { EventBus, getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
EventBus,
|
||||
getEventBus,
|
||||
removeEventBus,
|
||||
getAllEventBuses,
|
||||
} from '../transport/event-bus'
|
||||
|
||||
describe("EventBus", () => {
|
||||
let bus: EventBus;
|
||||
describe('EventBus', () => {
|
||||
let bus: EventBus
|
||||
|
||||
beforeEach(() => {
|
||||
bus = new EventBus();
|
||||
});
|
||||
bus = new EventBus()
|
||||
})
|
||||
|
||||
describe("publish", () => {
|
||||
test("publishes event with seqNum starting at 1", () => {
|
||||
describe('publish', () => {
|
||||
test('publishes event with seqNum starting at 1', () => {
|
||||
const event = bus.publish({
|
||||
id: "e1",
|
||||
sessionId: "s1",
|
||||
type: "user",
|
||||
payload: { content: "hello" },
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(event.seqNum).toBe(1);
|
||||
expect(event.createdAt).toBeGreaterThan(0);
|
||||
});
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: { content: 'hello' },
|
||||
direction: 'outbound',
|
||||
})
|
||||
expect(event.seqNum).toBe(1)
|
||||
expect(event.createdAt).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("increments seqNum on each publish", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||
const event = bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
|
||||
expect(event.seqNum).toBe(3);
|
||||
});
|
||||
test('increments seqNum on each publish', () => {
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
bus.publish({
|
||||
id: 'e2',
|
||||
sessionId: 's1',
|
||||
type: 'assistant',
|
||||
payload: {},
|
||||
direction: 'inbound',
|
||||
})
|
||||
const event = bus.publish({
|
||||
id: 'e3',
|
||||
sessionId: 's1',
|
||||
type: 'result',
|
||||
payload: {},
|
||||
direction: 'inbound',
|
||||
})
|
||||
expect(event.seqNum).toBe(3)
|
||||
})
|
||||
|
||||
test("throws when publishing to a closed bus", () => {
|
||||
bus.close();
|
||||
test('throws when publishing to a closed bus', () => {
|
||||
bus.close()
|
||||
expect(() =>
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" }),
|
||||
).toThrow("EventBus is closed");
|
||||
});
|
||||
});
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
}),
|
||||
).toThrow('EventBus is closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe("subscribe", () => {
|
||||
test("receives published events", () => {
|
||||
const received: unknown[] = [];
|
||||
bus.subscribe((event) => received.push(event));
|
||||
describe('subscribe', () => {
|
||||
test('receives published events', () => {
|
||||
const received: unknown[] = []
|
||||
bus.subscribe(event => received.push(event))
|
||||
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: { content: "hi" }, direction: "outbound" });
|
||||
expect(received).toHaveLength(1);
|
||||
expect((received[0] as any).payload).toEqual({ content: "hi" });
|
||||
});
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: { content: 'hi' },
|
||||
direction: 'outbound',
|
||||
})
|
||||
expect(received).toHaveLength(1)
|
||||
expect((received[0] as any).payload).toEqual({ content: 'hi' })
|
||||
})
|
||||
|
||||
test("unsubscribe stops receiving events", () => {
|
||||
const received: unknown[] = [];
|
||||
const unsub = bus.subscribe((event) => received.push(event));
|
||||
unsub();
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
test('unsubscribe stops receiving events', () => {
|
||||
const received: unknown[] = []
|
||||
const unsub = bus.subscribe(event => received.push(event))
|
||||
unsub()
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
expect(received).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("multiple subscribers all receive events", () => {
|
||||
const r1: unknown[] = [];
|
||||
const r2: unknown[] = [];
|
||||
bus.subscribe((e) => r1.push(e));
|
||||
bus.subscribe((e) => r2.push(e));
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(r1).toHaveLength(1);
|
||||
expect(r2).toHaveLength(1);
|
||||
});
|
||||
test('multiple subscribers all receive events', () => {
|
||||
const r1: unknown[] = []
|
||||
const r2: unknown[] = []
|
||||
bus.subscribe(e => r1.push(e))
|
||||
bus.subscribe(e => r2.push(e))
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
expect(r1).toHaveLength(1)
|
||||
expect(r2).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("subscriber error does not affect other subscribers", () => {
|
||||
const received: unknown[] = [];
|
||||
test('subscriber error does not affect other subscribers', () => {
|
||||
const received: unknown[] = []
|
||||
bus.subscribe(() => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
bus.subscribe((e) => received.push(e));
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
throw new Error('boom')
|
||||
})
|
||||
bus.subscribe(e => received.push(e))
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("subscriberCount", () => {
|
||||
expect(bus.subscriberCount()).toBe(0);
|
||||
const unsub1 = bus.subscribe(() => {});
|
||||
expect(bus.subscriberCount()).toBe(1);
|
||||
const unsub2 = bus.subscribe(() => {});
|
||||
expect(bus.subscriberCount()).toBe(2);
|
||||
unsub1();
|
||||
expect(bus.subscriberCount()).toBe(1);
|
||||
});
|
||||
});
|
||||
test('subscriberCount', () => {
|
||||
expect(bus.subscriberCount()).toBe(0)
|
||||
const unsub1 = bus.subscribe(() => {})
|
||||
expect(bus.subscriberCount()).toBe(1)
|
||||
const unsub2 = bus.subscribe(() => {})
|
||||
expect(bus.subscriberCount()).toBe(2)
|
||||
unsub1()
|
||||
expect(bus.subscriberCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getEventsSince", () => {
|
||||
test("returns events after given seqNum", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||
bus.publish({ id: "e3", sessionId: "s1", type: "result", payload: {}, direction: "inbound" });
|
||||
describe('getEventsSince', () => {
|
||||
test('returns events after given seqNum', () => {
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
bus.publish({
|
||||
id: 'e2',
|
||||
sessionId: 's1',
|
||||
type: 'assistant',
|
||||
payload: {},
|
||||
direction: 'inbound',
|
||||
})
|
||||
bus.publish({
|
||||
id: 'e3',
|
||||
sessionId: 's1',
|
||||
type: 'result',
|
||||
payload: {},
|
||||
direction: 'inbound',
|
||||
})
|
||||
|
||||
const events = bus.getEventsSince(1);
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events[0].seqNum).toBe(2);
|
||||
expect(events[1].seqNum).toBe(3);
|
||||
});
|
||||
const events = bus.getEventsSince(1)
|
||||
expect(events).toHaveLength(2)
|
||||
expect(events[0].seqNum).toBe(2)
|
||||
expect(events[1].seqNum).toBe(3)
|
||||
})
|
||||
|
||||
test("returns empty for seqNum beyond last", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(bus.getEventsSince(1)).toHaveLength(0);
|
||||
});
|
||||
test('returns empty for seqNum beyond last', () => {
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
expect(bus.getEventsSince(1)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("returns all events when seqNum is 0", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "assistant", payload: {}, direction: "inbound" });
|
||||
expect(bus.getEventsSince(0)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
test('returns all events when seqNum is 0', () => {
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
bus.publish({
|
||||
id: 'e2',
|
||||
sessionId: 's1',
|
||||
type: 'assistant',
|
||||
payload: {},
|
||||
direction: 'inbound',
|
||||
})
|
||||
expect(bus.getEventsSince(0)).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLastSeqNum", () => {
|
||||
test("returns 0 for empty bus", () => {
|
||||
expect(bus.getLastSeqNum()).toBe(0);
|
||||
});
|
||||
describe('getLastSeqNum', () => {
|
||||
test('returns 0 for empty bus', () => {
|
||||
expect(bus.getLastSeqNum()).toBe(0)
|
||||
})
|
||||
|
||||
test("returns last seqNum after publishes", () => {
|
||||
bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s1", type: "user", payload: {}, direction: "outbound" });
|
||||
expect(bus.getLastSeqNum()).toBe(2);
|
||||
});
|
||||
});
|
||||
test('returns last seqNum after publishes', () => {
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
bus.publish({
|
||||
id: 'e2',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
expect(bus.getLastSeqNum()).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("close", () => {
|
||||
test("clears subscribers and prevents publishing", () => {
|
||||
bus.subscribe(() => {});
|
||||
bus.close();
|
||||
expect(bus.subscriberCount()).toBe(0);
|
||||
expect(() => bus.publish({ id: "e1", sessionId: "s1", type: "user", payload: {}, direction: "outbound" })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('close', () => {
|
||||
test('clears subscribers and prevents publishing', () => {
|
||||
bus.subscribe(() => {})
|
||||
bus.close()
|
||||
expect(bus.subscriberCount()).toBe(0)
|
||||
expect(() =>
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's1',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("EventBus registry", () => {
|
||||
describe('EventBus registry', () => {
|
||||
beforeEach(() => {
|
||||
// Clean up global registry
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
removeEventBus(key)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
describe("getEventBus", () => {
|
||||
test("creates new bus for unknown session", () => {
|
||||
const bus = getEventBus("s1");
|
||||
expect(bus).toBeInstanceOf(EventBus);
|
||||
expect(getAllEventBuses().has("s1")).toBe(true);
|
||||
});
|
||||
describe('getEventBus', () => {
|
||||
test('creates new bus for unknown session', () => {
|
||||
const bus = getEventBus('s1')
|
||||
expect(bus).toBeInstanceOf(EventBus)
|
||||
expect(getAllEventBuses().has('s1')).toBe(true)
|
||||
})
|
||||
|
||||
test("returns same bus for same session", () => {
|
||||
const bus1 = getEventBus("s1");
|
||||
const bus2 = getEventBus("s1");
|
||||
expect(bus1).toBe(bus2);
|
||||
});
|
||||
});
|
||||
test('returns same bus for same session', () => {
|
||||
const bus1 = getEventBus('s1')
|
||||
const bus2 = getEventBus('s1')
|
||||
expect(bus1).toBe(bus2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeEventBus", () => {
|
||||
test("removes and closes bus", () => {
|
||||
const bus = getEventBus("s2");
|
||||
removeEventBus("s2");
|
||||
expect(getAllEventBuses().has("s2")).toBe(false);
|
||||
expect(() => bus.publish({ id: "e1", sessionId: "s2", type: "user", payload: {}, direction: "outbound" })).toThrow();
|
||||
});
|
||||
describe('removeEventBus', () => {
|
||||
test('removes and closes bus', () => {
|
||||
const bus = getEventBus('s2')
|
||||
removeEventBus('s2')
|
||||
expect(getAllEventBuses().has('s2')).toBe(false)
|
||||
expect(() =>
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's2',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("no-op for non-existent bus", () => {
|
||||
expect(() => removeEventBus("nonexistent")).not.toThrow();
|
||||
});
|
||||
});
|
||||
test('no-op for non-existent bus', () => {
|
||||
expect(() => removeEventBus('nonexistent')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAllEventBuses", () => {
|
||||
test("returns all registered buses", () => {
|
||||
getEventBus("a");
|
||||
getEventBus("b");
|
||||
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('getAllEventBuses', () => {
|
||||
test('returns all registered buses', () => {
|
||||
getEventBus('a')
|
||||
getEventBus('b')
|
||||
expect(getAllEventBuses().size).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { describe, test, expect, beforeEach, afterAll, mock } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, afterAll, mock } from 'bun:test'
|
||||
|
||||
// Mock config before imports
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
host: '0.0.0.0',
|
||||
apiKeys: ['test-api-key'],
|
||||
baseUrl: 'http://localhost:3000',
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
disconnectTimeout: 300,
|
||||
webCorsOrigins: ["https://dashboard.example"],
|
||||
webCorsOrigins: ['https://dashboard.example'],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
}
|
||||
|
||||
mock.module("../config", () => ({
|
||||
mock.module('../config', () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
getBaseUrl: () => 'http://localhost:3000',
|
||||
}))
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { storeReset, storeCreateUser } from "../store";
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { storeReset, storeCreateUser } from '../store'
|
||||
import {
|
||||
apiKeyAuth,
|
||||
encodeWebSocketAuthProtocol,
|
||||
@@ -30,266 +30,266 @@ import {
|
||||
sessionIngressAuth,
|
||||
uuidAuth,
|
||||
getUuidFromRequest,
|
||||
} from "../auth/middleware";
|
||||
import { issueToken } from "../auth/token";
|
||||
import { generateWorkerJwt } from "../auth/jwt";
|
||||
} from '../auth/middleware'
|
||||
import { issueToken } from '../auth/token'
|
||||
import { generateWorkerJwt } from '../auth/jwt'
|
||||
import {
|
||||
getAllowedWebCorsOrigins,
|
||||
resolveWebCorsOrigin,
|
||||
webCorsOptions,
|
||||
} from "../auth/cors";
|
||||
} from '../auth/cors'
|
||||
|
||||
// Helper: create a test app with middleware and a simple handler
|
||||
function createTestApp() {
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
// Test route for apiKeyAuth
|
||||
app.get("/api-key-test", apiKeyAuth, (c) => {
|
||||
return c.json({ username: c.get("username") || null });
|
||||
});
|
||||
app.get('/api-key-test', apiKeyAuth, c => {
|
||||
return c.json({ username: c.get('username') || null })
|
||||
})
|
||||
|
||||
// Test route for sessionIngressAuth
|
||||
app.get("/ingress/:id", sessionIngressAuth, (c) => {
|
||||
return c.json({ ok: true, jwtPayload: c.get("jwtPayload") || null });
|
||||
});
|
||||
app.get('/ingress/:id', sessionIngressAuth, c => {
|
||||
return c.json({ ok: true, jwtPayload: c.get('jwtPayload') || null })
|
||||
})
|
||||
|
||||
// Test route for uuidAuth
|
||||
app.get("/uuid-test", uuidAuth, (c) => {
|
||||
return c.json({ uuid: c.get("uuid") });
|
||||
});
|
||||
app.get('/uuid-test', uuidAuth, c => {
|
||||
return c.json({ uuid: c.get('uuid') })
|
||||
})
|
||||
|
||||
// Test route for getUuidFromRequest
|
||||
app.get("/uuid-extract", (c) => {
|
||||
return c.json({ uuid: getUuidFromRequest(c) });
|
||||
});
|
||||
app.get('/uuid-extract', c => {
|
||||
return c.json({ uuid: getUuidFromRequest(c) })
|
||||
})
|
||||
|
||||
app.get("/ws-auth-token", (c) => {
|
||||
return c.json({ token: extractWebSocketAuthToken(c) ?? null });
|
||||
});
|
||||
app.get('/ws-auth-token', c => {
|
||||
return c.json({ token: extractWebSocketAuthToken(c) ?? null })
|
||||
})
|
||||
|
||||
return app;
|
||||
return app
|
||||
}
|
||||
|
||||
describe("Auth Middleware", () => {
|
||||
let app: Hono;
|
||||
describe('Auth Middleware', () => {
|
||||
let app: Hono
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
app = createTestApp();
|
||||
});
|
||||
storeReset()
|
||||
app = createTestApp()
|
||||
})
|
||||
|
||||
describe("apiKeyAuth", () => {
|
||||
test("accepts valid API key with username header", async () => {
|
||||
const res = await app.request("/api-key-test", {
|
||||
describe('apiKeyAuth', () => {
|
||||
test('accepts valid API key with username header', async () => {
|
||||
const res = await app.request('/api-key-test', {
|
||||
headers: {
|
||||
Authorization: "Bearer test-api-key",
|
||||
"X-Username": "alice",
|
||||
Authorization: 'Bearer test-api-key',
|
||||
'X-Username': 'alice',
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("alice");
|
||||
});
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.username).toBe('alice')
|
||||
})
|
||||
|
||||
test("accepts valid API key with username query param", async () => {
|
||||
const res = await app.request("/api-key-test?username=bob", {
|
||||
headers: { Authorization: "Bearer test-api-key" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("bob");
|
||||
});
|
||||
test('accepts valid API key with username query param', async () => {
|
||||
const res = await app.request('/api-key-test?username=bob', {
|
||||
headers: { Authorization: 'Bearer test-api-key' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.username).toBe('bob')
|
||||
})
|
||||
|
||||
test("accepts valid session token", async () => {
|
||||
storeCreateUser("charlie");
|
||||
const { token } = issueToken("charlie");
|
||||
const res = await app.request("/api-key-test", {
|
||||
test('accepts valid session token', async () => {
|
||||
storeCreateUser('charlie')
|
||||
const { token } = issueToken('charlie')
|
||||
const res = await app.request('/api-key-test', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.username).toBe("charlie");
|
||||
});
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.username).toBe('charlie')
|
||||
})
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const res = await app.request("/api-key-test", {
|
||||
headers: { Authorization: "Bearer wrong-key" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
test('rejects invalid token', async () => {
|
||||
const res = await app.request('/api-key-test', {
|
||||
headers: { Authorization: 'Bearer wrong-key' },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("rejects missing token", async () => {
|
||||
const res = await app.request("/api-key-test");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
test('rejects missing token', async () => {
|
||||
const res = await app.request('/api-key-test')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("rejects session token from query param", async () => {
|
||||
storeCreateUser("dave");
|
||||
const { token } = issueToken("dave");
|
||||
const res = await app.request(`/api-key-test?token=${token}`);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
test('rejects session token from query param', async () => {
|
||||
storeCreateUser('dave')
|
||||
const { token } = issueToken('dave')
|
||||
const res = await app.request(`/api-key-test?token=${token}`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sessionIngressAuth", () => {
|
||||
const originalKeys = process.env.RCS_API_KEYS;
|
||||
describe('sessionIngressAuth', () => {
|
||||
const originalKeys = process.env.RCS_API_KEYS
|
||||
beforeEach(() => {
|
||||
process.env.RCS_API_KEYS = "test-api-key";
|
||||
});
|
||||
process.env.RCS_API_KEYS = 'test-api-key'
|
||||
})
|
||||
afterAll(() => {
|
||||
process.env.RCS_API_KEYS = originalKeys;
|
||||
});
|
||||
process.env.RCS_API_KEYS = originalKeys
|
||||
})
|
||||
|
||||
test("accepts valid API key", async () => {
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
headers: { Authorization: "Bearer test-api-key" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
test('accepts valid API key', async () => {
|
||||
const res = await app.request('/ingress/ses_123', {
|
||||
headers: { Authorization: 'Bearer test-api-key' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
test("accepts API key from WebSocket protocol header", async () => {
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
test('accepts API key from WebSocket protocol header', async () => {
|
||||
const res = await app.request('/ingress/ses_123', {
|
||||
headers: {
|
||||
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
|
||||
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
||||
},
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
test("accepts valid JWT with matching session_id", async () => {
|
||||
const jwt = generateWorkerJwt("ses_123", 3600);
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
test('accepts valid JWT with matching session_id', async () => {
|
||||
const jwt = generateWorkerJwt('ses_123', 3600)
|
||||
const res = await app.request('/ingress/ses_123', {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.jwtPayload).not.toBeNull();
|
||||
expect(body.jwtPayload.session_id).toBe("ses_123");
|
||||
});
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.jwtPayload).not.toBeNull()
|
||||
expect(body.jwtPayload.session_id).toBe('ses_123')
|
||||
})
|
||||
|
||||
test("rejects JWT with mismatched session_id", async () => {
|
||||
const jwt = generateWorkerJwt("ses_456", 3600);
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
test('rejects JWT with mismatched session_id', async () => {
|
||||
const jwt = generateWorkerJwt('ses_456', 3600)
|
||||
const res = await app.request('/ingress/ses_123', {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
})
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
test("rejects missing token", async () => {
|
||||
const res = await app.request("/ingress/ses_123");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
test('rejects missing token', async () => {
|
||||
const res = await app.request('/ingress/ses_123')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const res = await app.request("/ingress/ses_123", {
|
||||
headers: { Authorization: "Bearer invalid" },
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
test('rejects invalid token', async () => {
|
||||
const res = await app.request('/ingress/ses_123', {
|
||||
headers: { Authorization: 'Bearer invalid' },
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractWebSocketAuthToken", () => {
|
||||
test("does not read tokens from query params", async () => {
|
||||
const res = await app.request("/ws-auth-token?token=test-api-key");
|
||||
const body = await res.json();
|
||||
expect(body.token).toBeNull();
|
||||
});
|
||||
describe('extractWebSocketAuthToken', () => {
|
||||
test('does not read tokens from query params', async () => {
|
||||
const res = await app.request('/ws-auth-token?token=test-api-key')
|
||||
const body = await res.json()
|
||||
expect(body.token).toBeNull()
|
||||
})
|
||||
|
||||
test("reads tokens from WebSocket protocol header", async () => {
|
||||
const res = await app.request("/ws-auth-token", {
|
||||
test('reads tokens from WebSocket protocol header', async () => {
|
||||
const res = await app.request('/ws-auth-token', {
|
||||
headers: {
|
||||
"Sec-WebSocket-Protocol": encodeWebSocketAuthProtocol("test-api-key"),
|
||||
'Sec-WebSocket-Protocol': encodeWebSocketAuthProtocol('test-api-key'),
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.token).toBe("test-api-key");
|
||||
});
|
||||
});
|
||||
})
|
||||
const body = await res.json()
|
||||
expect(body.token).toBe('test-api-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe("uuidAuth", () => {
|
||||
test("accepts UUID from query param", async () => {
|
||||
const res = await app.request("/uuid-test?uuid=test-uuid-1");
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("test-uuid-1");
|
||||
});
|
||||
describe('uuidAuth', () => {
|
||||
test('accepts UUID from query param', async () => {
|
||||
const res = await app.request('/uuid-test?uuid=test-uuid-1')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.uuid).toBe('test-uuid-1')
|
||||
})
|
||||
|
||||
test("accepts UUID from header", async () => {
|
||||
const res = await app.request("/uuid-test", {
|
||||
headers: { "X-UUID": "test-uuid-2" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("test-uuid-2");
|
||||
});
|
||||
test('accepts UUID from header', async () => {
|
||||
const res = await app.request('/uuid-test', {
|
||||
headers: { 'X-UUID': 'test-uuid-2' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json()
|
||||
expect(body.uuid).toBe('test-uuid-2')
|
||||
})
|
||||
|
||||
test("rejects missing UUID", async () => {
|
||||
const res = await app.request("/uuid-test");
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
test('rejects missing UUID', async () => {
|
||||
const res = await app.request('/uuid-test')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUuidFromRequest", () => {
|
||||
test("extracts from query param", async () => {
|
||||
const res = await app.request("/uuid-extract?uuid=from-query");
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("from-query");
|
||||
});
|
||||
describe('getUuidFromRequest', () => {
|
||||
test('extracts from query param', async () => {
|
||||
const res = await app.request('/uuid-extract?uuid=from-query')
|
||||
const body = await res.json()
|
||||
expect(body.uuid).toBe('from-query')
|
||||
})
|
||||
|
||||
test("extracts from header", async () => {
|
||||
const res = await app.request("/uuid-extract", {
|
||||
headers: { "X-UUID": "from-header" },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBe("from-header");
|
||||
});
|
||||
test('extracts from header', async () => {
|
||||
const res = await app.request('/uuid-extract', {
|
||||
headers: { 'X-UUID': 'from-header' },
|
||||
})
|
||||
const body = await res.json()
|
||||
expect(body.uuid).toBe('from-header')
|
||||
})
|
||||
|
||||
test("returns undefined when no UUID", async () => {
|
||||
const res = await app.request("/uuid-extract");
|
||||
const body = await res.json();
|
||||
expect(body.uuid).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
test('returns undefined when no UUID', async () => {
|
||||
const res = await app.request('/uuid-extract')
|
||||
const body = await res.json()
|
||||
expect(body.uuid).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Web CORS", () => {
|
||||
describe('Web CORS', () => {
|
||||
function createCorsApp() {
|
||||
const corsApp = new Hono();
|
||||
corsApp.use("/web/*", cors(webCorsOptions));
|
||||
corsApp.get("/web/ping", (c) => c.text("ok"));
|
||||
return corsApp;
|
||||
const corsApp = new Hono()
|
||||
corsApp.use('/web/*', cors(webCorsOptions))
|
||||
corsApp.get('/web/ping', c => c.text('ok'))
|
||||
return corsApp
|
||||
}
|
||||
|
||||
test("allows configured origins plus local server origins", () => {
|
||||
expect(getAllowedWebCorsOrigins()).toContain("https://dashboard.example");
|
||||
expect(getAllowedWebCorsOrigins()).toContain("http://localhost:3000");
|
||||
expect(getAllowedWebCorsOrigins()).toContain("http://127.0.0.1:3000");
|
||||
expect(resolveWebCorsOrigin("https://dashboard.example")).toBe(
|
||||
"https://dashboard.example",
|
||||
);
|
||||
});
|
||||
test('allows configured origins plus local server origins', () => {
|
||||
expect(getAllowedWebCorsOrigins()).toContain('https://dashboard.example')
|
||||
expect(getAllowedWebCorsOrigins()).toContain('http://localhost:3000')
|
||||
expect(getAllowedWebCorsOrigins()).toContain('http://127.0.0.1:3000')
|
||||
expect(resolveWebCorsOrigin('https://dashboard.example')).toBe(
|
||||
'https://dashboard.example',
|
||||
)
|
||||
})
|
||||
|
||||
test("rejects unknown origins by default", () => {
|
||||
expect(resolveWebCorsOrigin("https://attacker.example")).toBeUndefined();
|
||||
});
|
||||
test('rejects unknown origins by default', () => {
|
||||
expect(resolveWebCorsOrigin('https://attacker.example')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not emit CORS allow-origin for unknown web origins", async () => {
|
||||
const res = await createCorsApp().request("/web/ping", {
|
||||
headers: { Origin: "https://attacker.example" },
|
||||
});
|
||||
test('does not emit CORS allow-origin for unknown web origins', async () => {
|
||||
const res = await createCorsApp().request('/web/ping', {
|
||||
headers: { Origin: 'https://attacker.example' },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
});
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
|
||||
})
|
||||
|
||||
test("emits CORS allow-origin for configured web origins", async () => {
|
||||
const res = await createCorsApp().request("/web/ping", {
|
||||
headers: { Origin: "https://dashboard.example" },
|
||||
});
|
||||
test('emits CORS allow-origin for configured web origins', async () => {
|
||||
const res = await createCorsApp().request('/web/ping', {
|
||||
headers: { Origin: 'https://dashboard.example' },
|
||||
})
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe(
|
||||
"https://dashboard.example",
|
||||
);
|
||||
});
|
||||
});
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Access-Control-Allow-Origin')).toBe(
|
||||
'https://dashboard.example',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
||||
|
||||
// Mock config before importing modules
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
host: '0.0.0.0',
|
||||
apiKeys: ['test-api-key'],
|
||||
baseUrl: 'http://localhost:3000',
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
@@ -13,14 +13,14 @@ const mockConfig = {
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
}
|
||||
|
||||
mock.module("../config", () => ({
|
||||
mock.module('../config', () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
getBaseUrl: () => 'http://localhost:3000',
|
||||
}))
|
||||
|
||||
import { storeReset, storeCreateEnvironment } from "../store";
|
||||
import { storeReset, storeCreateEnvironment } from '../store'
|
||||
import {
|
||||
createSession,
|
||||
createCodeSession,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
listSessionSummaries,
|
||||
listSessionSummariesByUsername,
|
||||
listSessionsByEnvironment,
|
||||
} from "../services/session";
|
||||
} from '../services/session'
|
||||
import {
|
||||
registerEnvironment,
|
||||
deregisterEnvironment,
|
||||
@@ -43,385 +43,404 @@ import {
|
||||
listActiveEnvironmentsResponse,
|
||||
listActiveEnvironmentsByUsername,
|
||||
reconnectEnvironment,
|
||||
} from "../services/environment";
|
||||
import { normalizePayload, publishSessionEvent } from "../services/transport";
|
||||
import { getEventBus, removeEventBus, getAllEventBuses } from "../transport/event-bus";
|
||||
} from '../services/environment'
|
||||
import { normalizePayload, publishSessionEvent } from '../services/transport'
|
||||
import {
|
||||
getEventBus,
|
||||
removeEventBus,
|
||||
getAllEventBuses,
|
||||
} from '../transport/event-bus'
|
||||
|
||||
// ---------- Session Service ----------
|
||||
|
||||
describe("Session Service", () => {
|
||||
describe('Session Service', () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
storeReset()
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
removeEventBus(key)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
describe("createSession", () => {
|
||||
test("creates a session with defaults", () => {
|
||||
const resp = createSession({});
|
||||
expect(resp.id).toMatch(/^session_/);
|
||||
expect(resp.status).toBe("idle");
|
||||
expect(resp.source).toBe("remote-control");
|
||||
expect(resp.environment_id).toBeNull();
|
||||
expect(resp.worker_epoch).toBe(0);
|
||||
expect(resp.created_at).toBeGreaterThan(0);
|
||||
});
|
||||
describe('createSession', () => {
|
||||
test('creates a session with defaults', () => {
|
||||
const resp = createSession({})
|
||||
expect(resp.id).toMatch(/^session_/)
|
||||
expect(resp.status).toBe('idle')
|
||||
expect(resp.source).toBe('remote-control')
|
||||
expect(resp.environment_id).toBeNull()
|
||||
expect(resp.worker_epoch).toBe(0)
|
||||
expect(resp.created_at).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test("creates a session with all options", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
test('creates a session with all options', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
const resp = createSession({
|
||||
environment_id: env.id,
|
||||
title: "My Session",
|
||||
source: "cli",
|
||||
permission_mode: "auto",
|
||||
});
|
||||
expect(resp.environment_id).toBe(env.id);
|
||||
expect(resp.title).toBe("My Session");
|
||||
expect(resp.source).toBe("cli");
|
||||
expect(resp.permission_mode).toBe("auto");
|
||||
});
|
||||
title: 'My Session',
|
||||
source: 'cli',
|
||||
permission_mode: 'auto',
|
||||
})
|
||||
expect(resp.environment_id).toBe(env.id)
|
||||
expect(resp.title).toBe('My Session')
|
||||
expect(resp.source).toBe('cli')
|
||||
expect(resp.permission_mode).toBe('auto')
|
||||
})
|
||||
|
||||
test("creates session with username", () => {
|
||||
const resp = createSession({ username: "alice" });
|
||||
expect(resp.username).toBe("alice");
|
||||
});
|
||||
});
|
||||
test('creates session with username', () => {
|
||||
const resp = createSession({ username: 'alice' })
|
||||
expect(resp.username).toBe('alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe("createCodeSession", () => {
|
||||
test("creates a code session with cse_ prefix", () => {
|
||||
const resp = createCodeSession({});
|
||||
expect(resp.id).toMatch(/^cse_/);
|
||||
});
|
||||
});
|
||||
describe('createCodeSession', () => {
|
||||
test('creates a code session with cse_ prefix', () => {
|
||||
const resp = createCodeSession({})
|
||||
expect(resp.id).toMatch(/^cse_/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSession", () => {
|
||||
test("returns null for non-existent session", () => {
|
||||
expect(getSession("nope")).toBeNull();
|
||||
});
|
||||
describe('getSession', () => {
|
||||
test('returns null for non-existent session', () => {
|
||||
expect(getSession('nope')).toBeNull()
|
||||
})
|
||||
|
||||
test("returns created session", () => {
|
||||
const created = createSession({});
|
||||
const fetched = getSession(created.id);
|
||||
expect(fetched).not.toBeNull();
|
||||
expect(fetched!.id).toBe(created.id);
|
||||
});
|
||||
});
|
||||
test('returns created session', () => {
|
||||
const created = createSession({})
|
||||
const fetched = getSession(created.id)
|
||||
expect(fetched).not.toBeNull()
|
||||
expect(fetched!.id).toBe(created.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateSessionTitle", () => {
|
||||
test("updates title", () => {
|
||||
const s = createSession({});
|
||||
updateSessionTitle(s.id, "New Title");
|
||||
expect(getSession(s.id)?.title).toBe("New Title");
|
||||
});
|
||||
});
|
||||
describe('updateSessionTitle', () => {
|
||||
test('updates title', () => {
|
||||
const s = createSession({})
|
||||
updateSessionTitle(s.id, 'New Title')
|
||||
expect(getSession(s.id)?.title).toBe('New Title')
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateSessionStatus", () => {
|
||||
test("updates status", () => {
|
||||
const s = createSession({});
|
||||
updateSessionStatus(s.id, "active");
|
||||
expect(getSession(s.id)?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
describe('updateSessionStatus', () => {
|
||||
test('updates status', () => {
|
||||
const s = createSession({})
|
||||
updateSessionStatus(s.id, 'active')
|
||||
expect(getSession(s.id)?.status).toBe('active')
|
||||
})
|
||||
})
|
||||
|
||||
describe("archiveSession", () => {
|
||||
test("sets status to archived and removes event bus", () => {
|
||||
const s = createSession({});
|
||||
describe('archiveSession', () => {
|
||||
test('sets status to archived and removes event bus', () => {
|
||||
const s = createSession({})
|
||||
// Create event bus for this session
|
||||
getEventBus(s.id);
|
||||
archiveSession(s.id);
|
||||
expect(getSession(s.id)?.status).toBe("archived");
|
||||
expect(getAllEventBuses().has(s.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
getEventBus(s.id)
|
||||
archiveSession(s.id)
|
||||
expect(getSession(s.id)?.status).toBe('archived')
|
||||
expect(getAllEventBuses().has(s.id)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("incrementEpoch", () => {
|
||||
test("increments epoch by 1", () => {
|
||||
const s = createSession({});
|
||||
expect(incrementEpoch(s.id)).toBe(1);
|
||||
expect(incrementEpoch(s.id)).toBe(2);
|
||||
expect(getSession(s.id)?.worker_epoch).toBe(2);
|
||||
});
|
||||
describe('incrementEpoch', () => {
|
||||
test('increments epoch by 1', () => {
|
||||
const s = createSession({})
|
||||
expect(incrementEpoch(s.id)).toBe(1)
|
||||
expect(incrementEpoch(s.id)).toBe(2)
|
||||
expect(getSession(s.id)?.worker_epoch).toBe(2)
|
||||
})
|
||||
|
||||
test("throws for non-existent session", () => {
|
||||
expect(() => incrementEpoch("nope")).toThrow("Session not found");
|
||||
});
|
||||
});
|
||||
test('throws for non-existent session', () => {
|
||||
expect(() => incrementEpoch('nope')).toThrow('Session not found')
|
||||
})
|
||||
})
|
||||
|
||||
describe("listSessions", () => {
|
||||
test("returns all sessions", () => {
|
||||
createSession({});
|
||||
createSession({});
|
||||
expect(listSessions()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
describe('listSessions', () => {
|
||||
test('returns all sessions', () => {
|
||||
createSession({})
|
||||
createSession({})
|
||||
expect(listSessions()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listSessionSummaries", () => {
|
||||
test("returns summaries with correct fields", () => {
|
||||
createSession({ title: "Test" });
|
||||
const summaries = listSessionSummaries();
|
||||
expect(summaries).toHaveLength(1);
|
||||
expect(summaries[0].title).toBe("Test");
|
||||
expect(summaries[0].updated_at).toBeGreaterThan(0);
|
||||
describe('listSessionSummaries', () => {
|
||||
test('returns summaries with correct fields', () => {
|
||||
createSession({ title: 'Test' })
|
||||
const summaries = listSessionSummaries()
|
||||
expect(summaries).toHaveLength(1)
|
||||
expect(summaries[0].title).toBe('Test')
|
||||
expect(summaries[0].updated_at).toBeGreaterThan(0)
|
||||
// Summary should not have environment_id
|
||||
expect("environment_id" in summaries[0]).toBe(false);
|
||||
});
|
||||
});
|
||||
expect('environment_id' in summaries[0]).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listSessionSummariesByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
createSession({ username: "alice" });
|
||||
createSession({ username: "bob" });
|
||||
expect(listSessionSummariesByUsername("alice")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('listSessionSummariesByUsername', () => {
|
||||
test('filters by username', () => {
|
||||
createSession({ username: 'alice' })
|
||||
createSession({ username: 'bob' })
|
||||
expect(listSessionSummariesByUsername('alice')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listSessionsByEnvironment", () => {
|
||||
test("filters by environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
createSession({ environment_id: env.id });
|
||||
createSession({});
|
||||
expect(listSessionsByEnvironment(env.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('listSessionsByEnvironment', () => {
|
||||
test('filters by environment', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
createSession({ environment_id: env.id })
|
||||
createSession({})
|
||||
expect(listSessionsByEnvironment(env.id)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Environment Service ----------
|
||||
|
||||
describe("Environment Service", () => {
|
||||
describe('Environment Service', () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
});
|
||||
storeReset()
|
||||
})
|
||||
|
||||
describe("registerEnvironment", () => {
|
||||
test("registers environment with defaults", () => {
|
||||
const result = registerEnvironment({});
|
||||
expect(result.environment_id).toMatch(/^env_/);
|
||||
expect(result.environment_secret).toBe("test-api-key");
|
||||
expect(result.status).toBe("active");
|
||||
});
|
||||
describe('registerEnvironment', () => {
|
||||
test('registers environment with defaults', () => {
|
||||
const result = registerEnvironment({})
|
||||
expect(result.environment_id).toMatch(/^env_/)
|
||||
expect(result.environment_secret).toBe('test-api-key')
|
||||
expect(result.status).toBe('active')
|
||||
})
|
||||
|
||||
test("registers with options", () => {
|
||||
test('registers with options', () => {
|
||||
const result = registerEnvironment({
|
||||
machine_name: "mac1",
|
||||
directory: "/home/user",
|
||||
branch: "main",
|
||||
git_repo_url: "https://github.com/test/repo",
|
||||
machine_name: 'mac1',
|
||||
directory: '/home/user',
|
||||
branch: 'main',
|
||||
git_repo_url: 'https://github.com/test/repo',
|
||||
max_sessions: 5,
|
||||
worker_type: "custom",
|
||||
});
|
||||
const env = getEnvironment(result.environment_id);
|
||||
expect(env?.machineName).toBe("mac1");
|
||||
expect(env?.directory).toBe("/home/user");
|
||||
expect(env?.maxSessions).toBe(5);
|
||||
});
|
||||
worker_type: 'custom',
|
||||
})
|
||||
const env = getEnvironment(result.environment_id)
|
||||
expect(env?.machineName).toBe('mac1')
|
||||
expect(env?.directory).toBe('/home/user')
|
||||
expect(env?.maxSessions).toBe(5)
|
||||
})
|
||||
|
||||
test("registers with username", () => {
|
||||
const result = registerEnvironment({ username: "alice" });
|
||||
const env = getEnvironment(result.environment_id);
|
||||
expect(env?.username).toBe("alice");
|
||||
});
|
||||
});
|
||||
test('registers with username', () => {
|
||||
const result = registerEnvironment({ username: 'alice' })
|
||||
const env = getEnvironment(result.environment_id)
|
||||
expect(env?.username).toBe('alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe("deregisterEnvironment", () => {
|
||||
test("sets status to deregistered", () => {
|
||||
const result = registerEnvironment({});
|
||||
deregisterEnvironment(result.environment_id);
|
||||
const env = getEnvironment(result.environment_id);
|
||||
expect(env?.status).toBe("deregistered");
|
||||
});
|
||||
});
|
||||
describe('deregisterEnvironment', () => {
|
||||
test('sets status to deregistered', () => {
|
||||
const result = registerEnvironment({})
|
||||
deregisterEnvironment(result.environment_id)
|
||||
const env = getEnvironment(result.environment_id)
|
||||
expect(env?.status).toBe('deregistered')
|
||||
})
|
||||
})
|
||||
|
||||
describe("updatePollTime", () => {
|
||||
test("updates lastPollAt", () => {
|
||||
const result = registerEnvironment({});
|
||||
const before = getEnvironment(result.environment_id)?.lastPollAt;
|
||||
describe('updatePollTime', () => {
|
||||
test('updates lastPollAt', () => {
|
||||
const result = registerEnvironment({})
|
||||
const before = getEnvironment(result.environment_id)?.lastPollAt
|
||||
// Small delay to ensure time difference
|
||||
updatePollTime(result.environment_id);
|
||||
const after = getEnvironment(result.environment_id)?.lastPollAt;
|
||||
expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime());
|
||||
});
|
||||
});
|
||||
updatePollTime(result.environment_id)
|
||||
const after = getEnvironment(result.environment_id)?.lastPollAt
|
||||
expect(after!.getTime()).toBeGreaterThanOrEqual(before!.getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe("listActiveEnvironments", () => {
|
||||
test("returns active environments", () => {
|
||||
registerEnvironment({});
|
||||
registerEnvironment({});
|
||||
expect(listActiveEnvironments()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
describe('listActiveEnvironments', () => {
|
||||
test('returns active environments', () => {
|
||||
registerEnvironment({})
|
||||
registerEnvironment({})
|
||||
expect(listActiveEnvironments()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listActiveEnvironmentsResponse", () => {
|
||||
test("returns response format", () => {
|
||||
registerEnvironment({ machine_name: "mac1" });
|
||||
const envs = listActiveEnvironmentsResponse();
|
||||
expect(envs).toHaveLength(1);
|
||||
expect(envs[0].machine_name).toBe("mac1");
|
||||
expect(envs[0].last_poll_at).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
describe('listActiveEnvironmentsResponse', () => {
|
||||
test('returns response format', () => {
|
||||
registerEnvironment({ machine_name: 'mac1' })
|
||||
const envs = listActiveEnvironmentsResponse()
|
||||
expect(envs).toHaveLength(1)
|
||||
expect(envs[0].machine_name).toBe('mac1')
|
||||
expect(envs[0].last_poll_at).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listActiveEnvironmentsByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
registerEnvironment({ username: "alice" });
|
||||
registerEnvironment({ username: "bob" });
|
||||
expect(listActiveEnvironmentsByUsername("alice")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('listActiveEnvironmentsByUsername', () => {
|
||||
test('filters by username', () => {
|
||||
registerEnvironment({ username: 'alice' })
|
||||
registerEnvironment({ username: 'bob' })
|
||||
expect(listActiveEnvironmentsByUsername('alice')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reconnectEnvironment", () => {
|
||||
test("sets status back to active", () => {
|
||||
const result = registerEnvironment({});
|
||||
deregisterEnvironment(result.environment_id);
|
||||
expect(getEnvironment(result.environment_id)?.status).toBe("deregistered");
|
||||
reconnectEnvironment(result.environment_id);
|
||||
expect(getEnvironment(result.environment_id)?.status).toBe("active");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('reconnectEnvironment', () => {
|
||||
test('sets status back to active', () => {
|
||||
const result = registerEnvironment({})
|
||||
deregisterEnvironment(result.environment_id)
|
||||
expect(getEnvironment(result.environment_id)?.status).toBe('deregistered')
|
||||
reconnectEnvironment(result.environment_id)
|
||||
expect(getEnvironment(result.environment_id)?.status).toBe('active')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Transport Service ----------
|
||||
|
||||
describe("Transport Service", () => {
|
||||
describe('Transport Service', () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
storeReset()
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
removeEventBus(key)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
describe("normalizePayload", () => {
|
||||
test("handles string payload", () => {
|
||||
const result = normalizePayload("user", "hello world");
|
||||
expect(result.content).toBe("hello world");
|
||||
expect(result.raw).toBe("hello world");
|
||||
});
|
||||
describe('normalizePayload', () => {
|
||||
test('handles string payload', () => {
|
||||
const result = normalizePayload('user', 'hello world')
|
||||
expect(result.content).toBe('hello world')
|
||||
expect(result.raw).toBe('hello world')
|
||||
})
|
||||
|
||||
test("handles null payload", () => {
|
||||
const result = normalizePayload("user", null);
|
||||
expect(result.content).toBe("");
|
||||
expect(result.raw).toBeNull();
|
||||
});
|
||||
test('handles null payload', () => {
|
||||
const result = normalizePayload('user', null)
|
||||
expect(result.content).toBe('')
|
||||
expect(result.raw).toBeNull()
|
||||
})
|
||||
|
||||
test("handles object with direct content", () => {
|
||||
const result = normalizePayload("user", { content: "direct text" });
|
||||
expect(result.content).toBe("direct text");
|
||||
});
|
||||
test('handles object with direct content', () => {
|
||||
const result = normalizePayload('user', { content: 'direct text' })
|
||||
expect(result.content).toBe('direct text')
|
||||
})
|
||||
|
||||
test("handles object with message.content string", () => {
|
||||
const result = normalizePayload("assistant", { message: { role: "assistant", content: "reply" } });
|
||||
expect(result.content).toBe("reply");
|
||||
});
|
||||
test('handles object with message.content string', () => {
|
||||
const result = normalizePayload('assistant', {
|
||||
message: { role: 'assistant', content: 'reply' },
|
||||
})
|
||||
expect(result.content).toBe('reply')
|
||||
})
|
||||
|
||||
test("handles object with message.content array", () => {
|
||||
const result = normalizePayload("assistant", {
|
||||
test('handles object with message.content array', () => {
|
||||
const result = normalizePayload('assistant', {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "hello " },
|
||||
{ type: "text", text: "world" },
|
||||
{ type: 'text', text: 'hello ' },
|
||||
{ type: 'text', text: 'world' },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.content).toBe("hello world");
|
||||
});
|
||||
})
|
||||
expect(result.content).toBe('hello world')
|
||||
})
|
||||
|
||||
test("preserves tool fields", () => {
|
||||
const result = normalizePayload("tool_use", { tool_name: "Bash", tool_input: { cmd: "ls" } });
|
||||
expect(result.tool_name).toBe("Bash");
|
||||
expect(result.tool_input).toEqual({ cmd: "ls" });
|
||||
});
|
||||
test('preserves tool fields', () => {
|
||||
const result = normalizePayload('tool_use', {
|
||||
tool_name: 'Bash',
|
||||
tool_input: { cmd: 'ls' },
|
||||
})
|
||||
expect(result.tool_name).toBe('Bash')
|
||||
expect(result.tool_input).toEqual({ cmd: 'ls' })
|
||||
})
|
||||
|
||||
test("preserves permission fields", () => {
|
||||
const result = normalizePayload("permission", {
|
||||
request_id: "req_1",
|
||||
test('preserves permission fields', () => {
|
||||
const result = normalizePayload('permission', {
|
||||
request_id: 'req_1',
|
||||
approved: true,
|
||||
updated_input: { cmd: "ls -la" },
|
||||
});
|
||||
expect(result.request_id).toBe("req_1");
|
||||
expect(result.approved).toBe(true);
|
||||
expect(result.updated_input).toEqual({ cmd: "ls -la" });
|
||||
});
|
||||
updated_input: { cmd: 'ls -la' },
|
||||
})
|
||||
expect(result.request_id).toBe('req_1')
|
||||
expect(result.approved).toBe(true)
|
||||
expect(result.updated_input).toEqual({ cmd: 'ls -la' })
|
||||
})
|
||||
|
||||
test("preserves message field", () => {
|
||||
const msg = { role: "user", content: "hi" };
|
||||
const result = normalizePayload("user", { message: msg });
|
||||
expect(result.message).toEqual(msg);
|
||||
});
|
||||
test('preserves message field', () => {
|
||||
const msg = { role: 'user', content: 'hi' }
|
||||
const result = normalizePayload('user', { message: msg })
|
||||
expect(result.message).toEqual(msg)
|
||||
})
|
||||
|
||||
test("preserves uuid field", () => {
|
||||
const result = normalizePayload("user", {
|
||||
uuid: "msg_123",
|
||||
content: "hi",
|
||||
});
|
||||
expect(result.uuid).toBe("msg_123");
|
||||
});
|
||||
test('preserves uuid field', () => {
|
||||
const result = normalizePayload('user', {
|
||||
uuid: 'msg_123',
|
||||
content: 'hi',
|
||||
})
|
||||
expect(result.uuid).toBe('msg_123')
|
||||
})
|
||||
|
||||
test("preserves isSynthetic field", () => {
|
||||
const result = normalizePayload("user", {
|
||||
content: "scheduled job: refresh analytics cache",
|
||||
test('preserves isSynthetic field', () => {
|
||||
const result = normalizePayload('user', {
|
||||
content: 'scheduled job: refresh analytics cache',
|
||||
isSynthetic: true,
|
||||
});
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
})
|
||||
expect(result.isSynthetic).toBe(true)
|
||||
})
|
||||
|
||||
test("uses name as tool_name fallback", () => {
|
||||
const result = normalizePayload("tool", { name: "Read" });
|
||||
expect(result.tool_name).toBe("Read");
|
||||
});
|
||||
test('uses name as tool_name fallback', () => {
|
||||
const result = normalizePayload('tool', { name: 'Read' })
|
||||
expect(result.tool_name).toBe('Read')
|
||||
})
|
||||
|
||||
test("uses input as tool_input fallback", () => {
|
||||
const result = normalizePayload("tool", { input: { path: "/tmp" } });
|
||||
expect(result.tool_input).toEqual({ path: "/tmp" });
|
||||
});
|
||||
test('uses input as tool_input fallback', () => {
|
||||
const result = normalizePayload('tool', { input: { path: '/tmp' } })
|
||||
expect(result.tool_input).toEqual({ path: '/tmp' })
|
||||
})
|
||||
|
||||
test("handles empty content array", () => {
|
||||
const result = normalizePayload("assistant", {
|
||||
test('handles empty content array', () => {
|
||||
const result = normalizePayload('assistant', {
|
||||
message: { content: [] },
|
||||
});
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
})
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
test("preserves task_state fields", () => {
|
||||
const result = normalizePayload("task_state", {
|
||||
task_list_id: "team-alpha",
|
||||
tasks: [{ id: "1", subject: "Task 1", status: "pending" }],
|
||||
});
|
||||
expect(result.task_list_id).toBe("team-alpha");
|
||||
test('preserves task_state fields', () => {
|
||||
const result = normalizePayload('task_state', {
|
||||
task_list_id: 'team-alpha',
|
||||
tasks: [{ id: '1', subject: 'Task 1', status: 'pending' }],
|
||||
})
|
||||
expect(result.task_list_id).toBe('team-alpha')
|
||||
expect(result.tasks).toEqual([
|
||||
{ id: "1", subject: "Task 1", status: "pending" },
|
||||
]);
|
||||
});
|
||||
{ id: '1', subject: 'Task 1', status: 'pending' },
|
||||
])
|
||||
})
|
||||
|
||||
test("preserves status metadata for conversation reset events", () => {
|
||||
const result = normalizePayload("status", {
|
||||
status: "conversation_cleared",
|
||||
subtype: "status",
|
||||
message: "conversation_cleared",
|
||||
});
|
||||
expect(result.status).toBe("conversation_cleared");
|
||||
expect(result.subtype).toBe("status");
|
||||
expect(result.message).toBe("conversation_cleared");
|
||||
});
|
||||
test('preserves status metadata for conversation reset events', () => {
|
||||
const result = normalizePayload('status', {
|
||||
status: 'conversation_cleared',
|
||||
subtype: 'status',
|
||||
message: 'conversation_cleared',
|
||||
})
|
||||
expect(result.status).toBe('conversation_cleared')
|
||||
expect(result.subtype).toBe('status')
|
||||
expect(result.message).toBe('conversation_cleared')
|
||||
})
|
||||
|
||||
test("handles undefined payload", () => {
|
||||
const result = normalizePayload("user", undefined);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
});
|
||||
test('handles undefined payload', () => {
|
||||
const result = normalizePayload('user', undefined)
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe("publishSessionEvent", () => {
|
||||
test("publishes event to session bus", () => {
|
||||
const event = publishSessionEvent("s1", "user", { content: "hello" }, "outbound");
|
||||
expect(event.type).toBe("user");
|
||||
expect(event.direction).toBe("outbound");
|
||||
expect(event.sessionId).toBe("s1");
|
||||
expect(event.seqNum).toBe(1);
|
||||
});
|
||||
describe('publishSessionEvent', () => {
|
||||
test('publishes event to session bus', () => {
|
||||
const event = publishSessionEvent(
|
||||
's1',
|
||||
'user',
|
||||
{ content: 'hello' },
|
||||
'outbound',
|
||||
)
|
||||
expect(event.type).toBe('user')
|
||||
expect(event.direction).toBe('outbound')
|
||||
expect(event.sessionId).toBe('s1')
|
||||
expect(event.seqNum).toBe(1)
|
||||
})
|
||||
|
||||
test("normalizes payload before publishing", () => {
|
||||
const event = publishSessionEvent("s1", "assistant", { message: { content: "reply" } }, "inbound");
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
expect(payload.content).toBe("reply");
|
||||
});
|
||||
});
|
||||
});
|
||||
test('normalizes payload before publishing', () => {
|
||||
const event = publishSessionEvent(
|
||||
's1',
|
||||
'assistant',
|
||||
{ message: { content: 'reply' } },
|
||||
'inbound',
|
||||
)
|
||||
const payload = event.payload as Record<string, unknown>
|
||||
expect(payload.content).toBe('reply')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
||||
|
||||
// Mock config
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
host: '0.0.0.0',
|
||||
apiKeys: ['test-api-key'],
|
||||
baseUrl: 'http://localhost:3000',
|
||||
pollTimeout: 8,
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
@@ -13,170 +13,201 @@ const mockConfig = {
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
|
||||
mock.module("../config", () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { storeReset } from "../store";
|
||||
import { removeEventBus, getAllEventBuses, getEventBus } from "../transport/event-bus";
|
||||
import { createSSEWriter, createSSEStream } from "../transport/sse-writer";
|
||||
|
||||
/** Read up to N bytes from a Response stream, then cancel */
|
||||
async function readPartialStream(res: Response, maxBytes = 4096): Promise<string> {
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) return "";
|
||||
const chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (totalBytes < maxBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
totalBytes += value.length;
|
||||
// Cancel after we have some data (first keepalive + any initial events)
|
||||
if (totalBytes > 0) break;
|
||||
}
|
||||
} finally {
|
||||
reader.cancel();
|
||||
}
|
||||
const combined = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return new TextDecoder().decode(combined);
|
||||
}
|
||||
|
||||
describe("SSE Writer", () => {
|
||||
describe("createSSEWriter", () => {
|
||||
test("creates SSEWriter with send and close methods", () => {
|
||||
const app = new Hono();
|
||||
let capturedWriter: ReturnType<typeof createSSEWriter> | null = null;
|
||||
mock.module('../config', () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => 'http://localhost:3000',
|
||||
}))
|
||||
|
||||
app.get("/test", (c) => {
|
||||
capturedWriter = createSSEWriter(c);
|
||||
return c.text("ok");
|
||||
});
|
||||
import { Hono } from 'hono'
|
||||
import { storeReset } from '../store'
|
||||
import {
|
||||
removeEventBus,
|
||||
getAllEventBuses,
|
||||
getEventBus,
|
||||
} from '../transport/event-bus'
|
||||
import { createSSEWriter, createSSEStream } from '../transport/sse-writer'
|
||||
|
||||
app.request("/test");
|
||||
expect(capturedWriter).not.toBeNull();
|
||||
expect(typeof capturedWriter!.send).toBe("function");
|
||||
expect(typeof capturedWriter!.close).toBe("function");
|
||||
});
|
||||
});
|
||||
/** Read up to N bytes from a Response stream, then cancel */
|
||||
async function readPartialStream(
|
||||
res: Response,
|
||||
maxBytes = 4096,
|
||||
): Promise<string> {
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) return ''
|
||||
const chunks: Uint8Array[] = []
|
||||
let totalBytes = 0
|
||||
try {
|
||||
while (totalBytes < maxBytes) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
totalBytes += value.length
|
||||
// Cancel after we have some data (first keepalive + any initial events)
|
||||
if (totalBytes > 0) break
|
||||
}
|
||||
} finally {
|
||||
reader.cancel()
|
||||
}
|
||||
const combined = new Uint8Array(totalBytes)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
return new TextDecoder().decode(combined)
|
||||
}
|
||||
|
||||
describe("createSSEStream", () => {
|
||||
describe('SSE Writer', () => {
|
||||
describe('createSSEWriter', () => {
|
||||
test('creates SSEWriter with send and close methods', () => {
|
||||
const app = new Hono()
|
||||
let capturedWriter: ReturnType<typeof createSSEWriter> | null = null
|
||||
|
||||
app.get('/test', c => {
|
||||
capturedWriter = createSSEWriter(c)
|
||||
return c.text('ok')
|
||||
})
|
||||
|
||||
app.request('/test')
|
||||
expect(capturedWriter).not.toBeNull()
|
||||
expect(typeof capturedWriter!.send).toBe('function')
|
||||
expect(typeof capturedWriter!.close).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createSSEStream', () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
storeReset()
|
||||
for (const [key] of getAllEventBuses()) {
|
||||
removeEventBus(key);
|
||||
removeEventBus(key)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("returns Response with correct SSE headers", async () => {
|
||||
const app = new Hono();
|
||||
test('returns Response with correct SSE headers', async () => {
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
app.get('/stream/:sessionId', c => {
|
||||
const sessionId = c.req.param('sessionId')
|
||||
return createSSEStream(c, sessionId, 0)
|
||||
})
|
||||
|
||||
const res = await app.request("/stream/s1");
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("text/event-stream");
|
||||
expect(res.headers.get("Cache-Control")).toBe("no-cache");
|
||||
expect(res.headers.get("Connection")).toBe("keep-alive");
|
||||
expect(res.headers.get("X-Accel-Buffering")).toBe("no");
|
||||
const res = await app.request('/stream/s1')
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('Content-Type')).toBe('text/event-stream')
|
||||
expect(res.headers.get('Cache-Control')).toBe('no-cache')
|
||||
expect(res.headers.get('Connection')).toBe('keep-alive')
|
||||
expect(res.headers.get('X-Accel-Buffering')).toBe('no')
|
||||
|
||||
// Cancel the stream
|
||||
res.body?.cancel();
|
||||
});
|
||||
res.body?.cancel()
|
||||
})
|
||||
|
||||
test("sends initial keepalive", async () => {
|
||||
const app = new Hono();
|
||||
test('sends initial keepalive', async () => {
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
app.get('/stream/:sessionId', c => {
|
||||
const sessionId = c.req.param('sessionId')
|
||||
return createSSEStream(c, sessionId, 0)
|
||||
})
|
||||
|
||||
const res = await app.request("/stream/s2");
|
||||
const text = await readPartialStream(res);
|
||||
expect(text).toContain(": keepalive");
|
||||
});
|
||||
const res = await app.request('/stream/s2')
|
||||
const text = await readPartialStream(res)
|
||||
expect(text).toContain(': keepalive')
|
||||
})
|
||||
|
||||
test("sends historical events when fromSeqNum > 0", async () => {
|
||||
test('sends historical events when fromSeqNum > 0', async () => {
|
||||
// Pre-populate event bus with events
|
||||
const bus = getEventBus("s3");
|
||||
bus.publish({ id: "e1", sessionId: "s3", type: "user", payload: { content: "hello" }, direction: "outbound" });
|
||||
bus.publish({ id: "e2", sessionId: "s3", type: "assistant", payload: { content: "hi" }, direction: "inbound" });
|
||||
const bus = getEventBus('s3')
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's3',
|
||||
type: 'user',
|
||||
payload: { content: 'hello' },
|
||||
direction: 'outbound',
|
||||
})
|
||||
bus.publish({
|
||||
id: 'e2',
|
||||
sessionId: 's3',
|
||||
type: 'assistant',
|
||||
payload: { content: 'hi' },
|
||||
direction: 'inbound',
|
||||
})
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
const fromSeq = parseInt(c.req.query("fromSeq") || "0");
|
||||
return createSSEStream(c, sessionId, fromSeq);
|
||||
});
|
||||
app.get('/stream/:sessionId', c => {
|
||||
const sessionId = c.req.param('sessionId')
|
||||
const fromSeq = parseInt(c.req.query('fromSeq') || '0', 10)
|
||||
return createSSEStream(c, sessionId, fromSeq)
|
||||
})
|
||||
|
||||
const res = await app.request("/stream/s3?fromSeq=1");
|
||||
const text = await readPartialStream(res);
|
||||
const res = await app.request('/stream/s3?fromSeq=1')
|
||||
const text = await readPartialStream(res)
|
||||
// Should replay events since seq 1 (i.e., event 2)
|
||||
expect(text).toContain('"seqNum":2');
|
||||
expect(text).toContain("assistant");
|
||||
});
|
||||
expect(text).toContain('"seqNum":2')
|
||||
expect(text).toContain('assistant')
|
||||
})
|
||||
|
||||
test("no historical events when fromSeqNum is 0", async () => {
|
||||
const bus = getEventBus("s5");
|
||||
bus.publish({ id: "e1", sessionId: "s5", type: "user", payload: {}, direction: "outbound" });
|
||||
test('no historical events when fromSeqNum is 0', async () => {
|
||||
const bus = getEventBus('s5')
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's5',
|
||||
type: 'user',
|
||||
payload: {},
|
||||
direction: 'outbound',
|
||||
})
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
app.get('/stream/:sessionId', c => {
|
||||
const sessionId = c.req.param('sessionId')
|
||||
return createSSEStream(c, sessionId, 0)
|
||||
})
|
||||
|
||||
const res = await app.request("/stream/s5");
|
||||
const text = await readPartialStream(res);
|
||||
const res = await app.request('/stream/s5')
|
||||
const text = await readPartialStream(res)
|
||||
// With fromSeqNum=0, no historical replay, just keepalive
|
||||
expect(text).toContain(": keepalive");
|
||||
expect(text).toContain(': keepalive')
|
||||
// Should NOT contain event data (only keepalive)
|
||||
expect(text).not.toContain("event: message");
|
||||
});
|
||||
expect(text).not.toContain('event: message')
|
||||
})
|
||||
|
||||
test("subscribes to new events and delivers them", async () => {
|
||||
const app = new Hono();
|
||||
test('subscribes to new events and delivers them', async () => {
|
||||
const app = new Hono()
|
||||
|
||||
app.get("/stream/:sessionId", (c) => {
|
||||
const sessionId = c.req.param("sessionId");
|
||||
return createSSEStream(c, sessionId, 0);
|
||||
});
|
||||
app.get('/stream/:sessionId', c => {
|
||||
const sessionId = c.req.param('sessionId')
|
||||
return createSSEStream(c, sessionId, 0)
|
||||
})
|
||||
|
||||
const res = await app.request("/stream/s6");
|
||||
const res = await app.request('/stream/s6')
|
||||
|
||||
// Read initial keepalive first
|
||||
const reader = res.body!.getReader();
|
||||
const { value: firstChunk } = await reader.read();
|
||||
const initialText = new TextDecoder().decode(firstChunk!);
|
||||
expect(initialText).toContain(": keepalive");
|
||||
const reader = res.body!.getReader()
|
||||
const { value: firstChunk } = await reader.read()
|
||||
const initialText = new TextDecoder().decode(firstChunk!)
|
||||
expect(initialText).toContain(': keepalive')
|
||||
|
||||
// Now publish an event
|
||||
const bus = getEventBus("s6");
|
||||
bus.publish({ id: "e1", sessionId: "s6", type: "user", payload: { content: "real-time" }, direction: "outbound" });
|
||||
const bus = getEventBus('s6')
|
||||
bus.publish({
|
||||
id: 'e1',
|
||||
sessionId: 's6',
|
||||
type: 'user',
|
||||
payload: { content: 'real-time' },
|
||||
direction: 'outbound',
|
||||
})
|
||||
|
||||
// Read the event
|
||||
const { value: secondChunk } = await reader.read();
|
||||
const eventText = new TextDecoder().decode(secondChunk!);
|
||||
expect(eventText).toContain("event: message");
|
||||
expect(eventText).toContain("real-time");
|
||||
const { value: secondChunk } = await reader.read()
|
||||
const eventText = new TextDecoder().decode(secondChunk!)
|
||||
expect(eventText).toContain('event: message')
|
||||
expect(eventText).toContain('real-time')
|
||||
|
||||
reader.cancel();
|
||||
});
|
||||
});
|
||||
});
|
||||
reader.cancel()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test";
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
storeReset,
|
||||
storeCreateUser,
|
||||
@@ -25,372 +25,401 @@ import {
|
||||
storeGetWorkItem,
|
||||
storeGetPendingWorkItem,
|
||||
storeUpdateWorkItem,
|
||||
} from "../store";
|
||||
} from '../store'
|
||||
|
||||
describe("store", () => {
|
||||
describe('store', () => {
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
});
|
||||
storeReset()
|
||||
})
|
||||
|
||||
// ---------- User ----------
|
||||
|
||||
describe("storeCreateUser", () => {
|
||||
test("creates a new user", () => {
|
||||
const user = storeCreateUser("alice");
|
||||
expect(user.username).toBe("alice");
|
||||
expect(user.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
describe('storeCreateUser', () => {
|
||||
test('creates a new user', () => {
|
||||
const user = storeCreateUser('alice')
|
||||
expect(user.username).toBe('alice')
|
||||
expect(user.createdAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("returns existing user on duplicate create", () => {
|
||||
const first = storeCreateUser("bob");
|
||||
const second = storeCreateUser("bob");
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
test('returns existing user on duplicate create', () => {
|
||||
const first = storeCreateUser('bob')
|
||||
const second = storeCreateUser('bob')
|
||||
expect(first).toBe(second)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeGetUser", () => {
|
||||
test("returns undefined for non-existent user", () => {
|
||||
expect(storeGetUser("nobody")).toBeUndefined();
|
||||
});
|
||||
describe('storeGetUser', () => {
|
||||
test('returns undefined for non-existent user', () => {
|
||||
expect(storeGetUser('nobody')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns created user", () => {
|
||||
storeCreateUser("charlie");
|
||||
const user = storeGetUser("charlie");
|
||||
expect(user?.username).toBe("charlie");
|
||||
});
|
||||
});
|
||||
test('returns created user', () => {
|
||||
storeCreateUser('charlie')
|
||||
const user = storeGetUser('charlie')
|
||||
expect(user?.username).toBe('charlie')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Token ----------
|
||||
|
||||
describe("storeCreateToken / storeGetUserByToken", () => {
|
||||
test("creates and resolves token", () => {
|
||||
storeCreateUser("dave");
|
||||
storeCreateToken("dave", "tk_123");
|
||||
const entry = storeGetUserByToken("tk_123");
|
||||
expect(entry?.username).toBe("dave");
|
||||
expect(entry?.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
describe('storeCreateToken / storeGetUserByToken', () => {
|
||||
test('creates and resolves token', () => {
|
||||
storeCreateUser('dave')
|
||||
storeCreateToken('dave', 'tk_123')
|
||||
const entry = storeGetUserByToken('tk_123')
|
||||
expect(entry?.username).toBe('dave')
|
||||
expect(entry?.createdAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("returns undefined for unknown token", () => {
|
||||
expect(storeGetUserByToken("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('returns undefined for unknown token', () => {
|
||||
expect(storeGetUserByToken('nonexistent')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeDeleteToken", () => {
|
||||
test("deletes an existing token", () => {
|
||||
storeCreateUser("eve");
|
||||
storeCreateToken("eve", "tk_del");
|
||||
expect(storeDeleteToken("tk_del")).toBe(true);
|
||||
expect(storeGetUserByToken("tk_del")).toBeUndefined();
|
||||
});
|
||||
describe('storeDeleteToken', () => {
|
||||
test('deletes an existing token', () => {
|
||||
storeCreateUser('eve')
|
||||
storeCreateToken('eve', 'tk_del')
|
||||
expect(storeDeleteToken('tk_del')).toBe(true)
|
||||
expect(storeGetUserByToken('tk_del')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns false for non-existent token", () => {
|
||||
expect(storeDeleteToken("nope")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for non-existent token', () => {
|
||||
expect(storeDeleteToken('nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Environment ----------
|
||||
|
||||
describe("storeCreateEnvironment", () => {
|
||||
test("creates environment with defaults", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s1" });
|
||||
expect(env.id).toMatch(/^env_/);
|
||||
expect(env.secret).toBe("s1");
|
||||
expect(env.status).toBe("active");
|
||||
expect(env.machineName).toBeNull();
|
||||
expect(env.maxSessions).toBe(1);
|
||||
expect(env.workerType).toBe("claude_code");
|
||||
expect(env.lastPollAt).toBeInstanceOf(Date);
|
||||
});
|
||||
describe('storeCreateEnvironment', () => {
|
||||
test('creates environment with defaults', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's1' })
|
||||
expect(env.id).toMatch(/^env_/)
|
||||
expect(env.secret).toBe('s1')
|
||||
expect(env.status).toBe('active')
|
||||
expect(env.machineName).toBeNull()
|
||||
expect(env.maxSessions).toBe(1)
|
||||
expect(env.workerType).toBe('claude_code')
|
||||
expect(env.lastPollAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
test("creates environment with all options", () => {
|
||||
test('creates environment with all options', () => {
|
||||
const env = storeCreateEnvironment({
|
||||
secret: "s2",
|
||||
machineName: "mac1",
|
||||
directory: "/home/user",
|
||||
branch: "main",
|
||||
gitRepoUrl: "https://github.com/test/repo",
|
||||
secret: 's2',
|
||||
machineName: 'mac1',
|
||||
directory: '/home/user',
|
||||
branch: 'main',
|
||||
gitRepoUrl: 'https://github.com/test/repo',
|
||||
maxSessions: 5,
|
||||
workerType: "custom",
|
||||
bridgeId: "bridge1",
|
||||
username: "alice",
|
||||
});
|
||||
expect(env.machineName).toBe("mac1");
|
||||
expect(env.directory).toBe("/home/user");
|
||||
expect(env.branch).toBe("main");
|
||||
expect(env.gitRepoUrl).toBe("https://github.com/test/repo");
|
||||
expect(env.maxSessions).toBe(5);
|
||||
expect(env.workerType).toBe("custom");
|
||||
expect(env.bridgeId).toBe("bridge1");
|
||||
expect(env.username).toBe("alice");
|
||||
});
|
||||
});
|
||||
workerType: 'custom',
|
||||
bridgeId: 'bridge1',
|
||||
username: 'alice',
|
||||
})
|
||||
expect(env.machineName).toBe('mac1')
|
||||
expect(env.directory).toBe('/home/user')
|
||||
expect(env.branch).toBe('main')
|
||||
expect(env.gitRepoUrl).toBe('https://github.com/test/repo')
|
||||
expect(env.maxSessions).toBe(5)
|
||||
expect(env.workerType).toBe('custom')
|
||||
expect(env.bridgeId).toBe('bridge1')
|
||||
expect(env.username).toBe('alice')
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeGetEnvironment", () => {
|
||||
test("returns undefined for non-existent env", () => {
|
||||
expect(storeGetEnvironment("env_no")).toBeUndefined();
|
||||
});
|
||||
describe('storeGetEnvironment', () => {
|
||||
test('returns undefined for non-existent env', () => {
|
||||
expect(storeGetEnvironment('env_no')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns created environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
expect(storeGetEnvironment(env.id)).toBe(env);
|
||||
});
|
||||
});
|
||||
test('returns created environment', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
expect(storeGetEnvironment(env.id)).toBe(env)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeUpdateEnvironment", () => {
|
||||
test("updates existing environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
const result = storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||
expect(result).toBe(true);
|
||||
const updated = storeGetEnvironment(env.id);
|
||||
expect(updated?.status).toBe("disconnected");
|
||||
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(env.updatedAt.getTime());
|
||||
});
|
||||
describe('storeUpdateEnvironment', () => {
|
||||
test('updates existing environment', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
const result = storeUpdateEnvironment(env.id, { status: 'disconnected' })
|
||||
expect(result).toBe(true)
|
||||
const updated = storeGetEnvironment(env.id)
|
||||
expect(updated?.status).toBe('disconnected')
|
||||
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
env.updatedAt.getTime(),
|
||||
)
|
||||
})
|
||||
|
||||
test("returns false for non-existent environment", () => {
|
||||
expect(storeUpdateEnvironment("env_no", { status: "active" })).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for non-existent environment', () => {
|
||||
expect(storeUpdateEnvironment('env_no', { status: 'active' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeListActiveEnvironments", () => {
|
||||
test("returns only active environments", () => {
|
||||
const env1 = storeCreateEnvironment({ secret: "s1" });
|
||||
const env2 = storeCreateEnvironment({ secret: "s2" });
|
||||
storeUpdateEnvironment(env1.id, { status: "deregistered" });
|
||||
const active = storeListActiveEnvironments();
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].id).toBe(env2.id);
|
||||
});
|
||||
});
|
||||
describe('storeListActiveEnvironments', () => {
|
||||
test('returns only active environments', () => {
|
||||
const env1 = storeCreateEnvironment({ secret: 's1' })
|
||||
const env2 = storeCreateEnvironment({ secret: 's2' })
|
||||
storeUpdateEnvironment(env1.id, { status: 'deregistered' })
|
||||
const active = storeListActiveEnvironments()
|
||||
expect(active).toHaveLength(1)
|
||||
expect(active[0].id).toBe(env2.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeListActiveEnvironmentsByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
storeCreateEnvironment({ secret: "s1", username: "alice" });
|
||||
storeCreateEnvironment({ secret: "s2", username: "bob" });
|
||||
const aliceEnvs = storeListActiveEnvironmentsByUsername("alice");
|
||||
expect(aliceEnvs).toHaveLength(1);
|
||||
expect(aliceEnvs[0].username).toBe("alice");
|
||||
});
|
||||
});
|
||||
describe('storeListActiveEnvironmentsByUsername', () => {
|
||||
test('filters by username', () => {
|
||||
storeCreateEnvironment({ secret: 's1', username: 'alice' })
|
||||
storeCreateEnvironment({ secret: 's2', username: 'bob' })
|
||||
const aliceEnvs = storeListActiveEnvironmentsByUsername('alice')
|
||||
expect(aliceEnvs).toHaveLength(1)
|
||||
expect(aliceEnvs[0].username).toBe('alice')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Session ----------
|
||||
|
||||
describe("storeCreateSession", () => {
|
||||
test("creates session with defaults", () => {
|
||||
const session = storeCreateSession({});
|
||||
expect(session.id).toMatch(/^session_/);
|
||||
expect(session.status).toBe("idle");
|
||||
expect(session.source).toBe("remote-control");
|
||||
expect(session.environmentId).toBeNull();
|
||||
expect(session.workerEpoch).toBe(0);
|
||||
});
|
||||
describe('storeCreateSession', () => {
|
||||
test('creates session with defaults', () => {
|
||||
const session = storeCreateSession({})
|
||||
expect(session.id).toMatch(/^session_/)
|
||||
expect(session.status).toBe('idle')
|
||||
expect(session.source).toBe('remote-control')
|
||||
expect(session.environmentId).toBeNull()
|
||||
expect(session.workerEpoch).toBe(0)
|
||||
})
|
||||
|
||||
test("creates session with options", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
test('creates session with options', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
const session = storeCreateSession({
|
||||
environmentId: env.id,
|
||||
title: "Test Session",
|
||||
source: "cli",
|
||||
permissionMode: "auto",
|
||||
username: "alice",
|
||||
});
|
||||
expect(session.environmentId).toBe(env.id);
|
||||
expect(session.title).toBe("Test Session");
|
||||
expect(session.source).toBe("cli");
|
||||
expect(session.permissionMode).toBe("auto");
|
||||
expect(session.username).toBe("alice");
|
||||
});
|
||||
title: 'Test Session',
|
||||
source: 'cli',
|
||||
permissionMode: 'auto',
|
||||
username: 'alice',
|
||||
})
|
||||
expect(session.environmentId).toBe(env.id)
|
||||
expect(session.title).toBe('Test Session')
|
||||
expect(session.source).toBe('cli')
|
||||
expect(session.permissionMode).toBe('auto')
|
||||
expect(session.username).toBe('alice')
|
||||
})
|
||||
|
||||
test("creates session with custom idPrefix", () => {
|
||||
const session = storeCreateSession({ idPrefix: "cse_" });
|
||||
expect(session.id).toMatch(/^cse_/);
|
||||
});
|
||||
});
|
||||
test('creates session with custom idPrefix', () => {
|
||||
const session = storeCreateSession({ idPrefix: 'cse_' })
|
||||
expect(session.id).toMatch(/^cse_/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeGetSession", () => {
|
||||
test("returns undefined for non-existent session", () => {
|
||||
expect(storeGetSession("nope")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
describe('storeGetSession', () => {
|
||||
test('returns undefined for non-existent session', () => {
|
||||
expect(storeGetSession('nope')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeUpdateSession", () => {
|
||||
test("updates existing session", () => {
|
||||
const session = storeCreateSession({});
|
||||
const result = storeUpdateSession(session.id, { title: "Updated", status: "active" });
|
||||
expect(result).toBe(true);
|
||||
const updated = storeGetSession(session.id);
|
||||
expect(updated?.title).toBe("Updated");
|
||||
expect(updated?.status).toBe("active");
|
||||
});
|
||||
describe('storeUpdateSession', () => {
|
||||
test('updates existing session', () => {
|
||||
const session = storeCreateSession({})
|
||||
const result = storeUpdateSession(session.id, {
|
||||
title: 'Updated',
|
||||
status: 'active',
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
const updated = storeGetSession(session.id)
|
||||
expect(updated?.title).toBe('Updated')
|
||||
expect(updated?.status).toBe('active')
|
||||
})
|
||||
|
||||
test("returns false for non-existent session", () => {
|
||||
expect(storeUpdateSession("nope", { title: "x" })).toBe(false);
|
||||
});
|
||||
test('returns false for non-existent session', () => {
|
||||
expect(storeUpdateSession('nope', { title: 'x' })).toBe(false)
|
||||
})
|
||||
|
||||
test("increments workerEpoch", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeUpdateSession(session.id, { workerEpoch: 1 });
|
||||
expect(storeGetSession(session.id)?.workerEpoch).toBe(1);
|
||||
});
|
||||
});
|
||||
test('increments workerEpoch', () => {
|
||||
const session = storeCreateSession({})
|
||||
storeUpdateSession(session.id, { workerEpoch: 1 })
|
||||
expect(storeGetSession(session.id)?.workerEpoch).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeListSessions", () => {
|
||||
test("returns all sessions", () => {
|
||||
storeCreateSession({});
|
||||
storeCreateSession({});
|
||||
expect(storeListSessions()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
describe('storeListSessions', () => {
|
||||
test('returns all sessions', () => {
|
||||
storeCreateSession({})
|
||||
storeCreateSession({})
|
||||
expect(storeListSessions()).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeListSessionsByUsername", () => {
|
||||
test("filters by username", () => {
|
||||
storeCreateSession({ username: "alice" });
|
||||
storeCreateSession({ username: "bob" });
|
||||
expect(storeListSessionsByUsername("alice")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('storeListSessionsByUsername', () => {
|
||||
test('filters by username', () => {
|
||||
storeCreateSession({ username: 'alice' })
|
||||
storeCreateSession({ username: 'bob' })
|
||||
expect(storeListSessionsByUsername('alice')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeListSessionsByEnvironment", () => {
|
||||
test("filters by environment", () => {
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
storeCreateSession({ environmentId: env.id });
|
||||
storeCreateSession({});
|
||||
expect(storeListSessionsByEnvironment(env.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
describe('storeListSessionsByEnvironment', () => {
|
||||
test('filters by environment', () => {
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
storeCreateSession({ environmentId: env.id })
|
||||
storeCreateSession({})
|
||||
expect(storeListSessionsByEnvironment(env.id)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeDeleteSession", () => {
|
||||
test("deletes existing session", () => {
|
||||
const session = storeCreateSession({});
|
||||
expect(storeDeleteSession(session.id)).toBe(true);
|
||||
expect(storeGetSession(session.id)).toBeUndefined();
|
||||
});
|
||||
describe('storeDeleteSession', () => {
|
||||
test('deletes existing session', () => {
|
||||
const session = storeCreateSession({})
|
||||
expect(storeDeleteSession(session.id)).toBe(true)
|
||||
expect(storeGetSession(session.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns false for non-existent session", () => {
|
||||
expect(storeDeleteSession("nope")).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for non-existent session', () => {
|
||||
expect(storeDeleteSession('nope')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Session Ownership ----------
|
||||
|
||||
describe("storeBindSession / storeIsSessionOwner", () => {
|
||||
test("binds and checks ownership", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeBindSession(session.id, "uuid-1");
|
||||
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
|
||||
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(false);
|
||||
});
|
||||
describe('storeBindSession / storeIsSessionOwner', () => {
|
||||
test('binds and checks ownership', () => {
|
||||
const session = storeCreateSession({})
|
||||
storeBindSession(session.id, 'uuid-1')
|
||||
expect(storeIsSessionOwner(session.id, 'uuid-1')).toBe(true)
|
||||
expect(storeIsSessionOwner(session.id, 'uuid-2')).toBe(false)
|
||||
})
|
||||
|
||||
test("unbound session has no owner", () => {
|
||||
const session = storeCreateSession({});
|
||||
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(false);
|
||||
});
|
||||
test('unbound session has no owner', () => {
|
||||
const session = storeCreateSession({})
|
||||
expect(storeIsSessionOwner(session.id, 'uuid-1')).toBe(false)
|
||||
})
|
||||
|
||||
test("multiple owners per session", () => {
|
||||
const session = storeCreateSession({});
|
||||
storeBindSession(session.id, "uuid-1");
|
||||
storeBindSession(session.id, "uuid-2");
|
||||
expect(storeIsSessionOwner(session.id, "uuid-1")).toBe(true);
|
||||
expect(storeIsSessionOwner(session.id, "uuid-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
test('multiple owners per session', () => {
|
||||
const session = storeCreateSession({})
|
||||
storeBindSession(session.id, 'uuid-1')
|
||||
storeBindSession(session.id, 'uuid-2')
|
||||
expect(storeIsSessionOwner(session.id, 'uuid-1')).toBe(true)
|
||||
expect(storeIsSessionOwner(session.id, 'uuid-2')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeListSessionsByOwnerUuid", () => {
|
||||
test("returns sessions owned by uuid", () => {
|
||||
const s1 = storeCreateSession({});
|
||||
const s2 = storeCreateSession({});
|
||||
storeBindSession(s1.id, "uuid-1");
|
||||
storeBindSession(s2.id, "uuid-1");
|
||||
const owned = storeListSessionsByOwnerUuid("uuid-1");
|
||||
expect(owned).toHaveLength(2);
|
||||
});
|
||||
describe('storeListSessionsByOwnerUuid', () => {
|
||||
test('returns sessions owned by uuid', () => {
|
||||
const s1 = storeCreateSession({})
|
||||
const s2 = storeCreateSession({})
|
||||
storeBindSession(s1.id, 'uuid-1')
|
||||
storeBindSession(s2.id, 'uuid-1')
|
||||
const owned = storeListSessionsByOwnerUuid('uuid-1')
|
||||
expect(owned).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("returns empty for unknown uuid", () => {
|
||||
expect(storeListSessionsByOwnerUuid("nope")).toHaveLength(0);
|
||||
});
|
||||
test('returns empty for unknown uuid', () => {
|
||||
expect(storeListSessionsByOwnerUuid('nope')).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("excludes deleted sessions", () => {
|
||||
const s1 = storeCreateSession({});
|
||||
storeBindSession(s1.id, "uuid-1");
|
||||
storeDeleteSession(s1.id);
|
||||
expect(storeListSessionsByOwnerUuid("uuid-1")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
test('excludes deleted sessions', () => {
|
||||
const s1 = storeCreateSession({})
|
||||
storeBindSession(s1.id, 'uuid-1')
|
||||
storeDeleteSession(s1.id)
|
||||
expect(storeListSessionsByOwnerUuid('uuid-1')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- Work Items ----------
|
||||
|
||||
describe("storeCreateWorkItem", () => {
|
||||
test("creates work item with defaults", () => {
|
||||
describe('storeCreateWorkItem', () => {
|
||||
test('creates work item with defaults', () => {
|
||||
const item = storeCreateWorkItem({
|
||||
environmentId: "env1",
|
||||
sessionId: "ses1",
|
||||
secret: "sec1",
|
||||
});
|
||||
expect(item.id).toMatch(/^work_/);
|
||||
expect(item.environmentId).toBe("env1");
|
||||
expect(item.sessionId).toBe("ses1");
|
||||
expect(item.state).toBe("pending");
|
||||
expect(item.secret).toBe("sec1");
|
||||
});
|
||||
});
|
||||
environmentId: 'env1',
|
||||
sessionId: 'ses1',
|
||||
secret: 'sec1',
|
||||
})
|
||||
expect(item.id).toMatch(/^work_/)
|
||||
expect(item.environmentId).toBe('env1')
|
||||
expect(item.sessionId).toBe('ses1')
|
||||
expect(item.state).toBe('pending')
|
||||
expect(item.secret).toBe('sec1')
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeGetWorkItem", () => {
|
||||
test("returns undefined for non-existent", () => {
|
||||
expect(storeGetWorkItem("nope")).toBeUndefined();
|
||||
});
|
||||
describe('storeGetWorkItem', () => {
|
||||
test('returns undefined for non-existent', () => {
|
||||
expect(storeGetWorkItem('nope')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("returns created work item", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
expect(storeGetWorkItem(item.id)).toBe(item);
|
||||
});
|
||||
});
|
||||
test('returns created work item', () => {
|
||||
const item = storeCreateWorkItem({
|
||||
environmentId: 'env1',
|
||||
sessionId: 'ses1',
|
||||
secret: 's',
|
||||
})
|
||||
expect(storeGetWorkItem(item.id)).toBe(item)
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeGetPendingWorkItem", () => {
|
||||
test("returns pending work for environment", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
const found = storeGetPendingWorkItem("env1");
|
||||
expect(found?.id).toBe(item.id);
|
||||
});
|
||||
describe('storeGetPendingWorkItem', () => {
|
||||
test('returns pending work for environment', () => {
|
||||
const item = storeCreateWorkItem({
|
||||
environmentId: 'env1',
|
||||
sessionId: 'ses1',
|
||||
secret: 's',
|
||||
})
|
||||
const found = storeGetPendingWorkItem('env1')
|
||||
expect(found?.id).toBe(item.id)
|
||||
})
|
||||
|
||||
test("returns undefined when no pending work", () => {
|
||||
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
expect(storeGetPendingWorkItem("env2")).toBeUndefined();
|
||||
});
|
||||
test('returns undefined when no pending work', () => {
|
||||
storeCreateWorkItem({
|
||||
environmentId: 'env1',
|
||||
sessionId: 'ses1',
|
||||
secret: 's',
|
||||
})
|
||||
expect(storeGetPendingWorkItem('env2')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("skips non-pending items", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
storeUpdateWorkItem(item.id, { state: "dispatched" });
|
||||
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('skips non-pending items', () => {
|
||||
const item = storeCreateWorkItem({
|
||||
environmentId: 'env1',
|
||||
sessionId: 'ses1',
|
||||
secret: 's',
|
||||
})
|
||||
storeUpdateWorkItem(item.id, { state: 'dispatched' })
|
||||
expect(storeGetPendingWorkItem('env1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("storeUpdateWorkItem", () => {
|
||||
test("updates existing work item", () => {
|
||||
const item = storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
expect(storeUpdateWorkItem(item.id, { state: "acked" })).toBe(true);
|
||||
expect(storeGetWorkItem(item.id)?.state).toBe("acked");
|
||||
});
|
||||
describe('storeUpdateWorkItem', () => {
|
||||
test('updates existing work item', () => {
|
||||
const item = storeCreateWorkItem({
|
||||
environmentId: 'env1',
|
||||
sessionId: 'ses1',
|
||||
secret: 's',
|
||||
})
|
||||
expect(storeUpdateWorkItem(item.id, { state: 'acked' })).toBe(true)
|
||||
expect(storeGetWorkItem(item.id)?.state).toBe('acked')
|
||||
})
|
||||
|
||||
test("returns false for non-existent", () => {
|
||||
expect(storeUpdateWorkItem("nope", { state: "acked" })).toBe(false);
|
||||
});
|
||||
});
|
||||
test('returns false for non-existent', () => {
|
||||
expect(storeUpdateWorkItem('nope', { state: 'acked' })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- storeReset ----------
|
||||
|
||||
describe("storeReset", () => {
|
||||
test("clears all data", () => {
|
||||
storeCreateUser("alice");
|
||||
storeCreateEnvironment({ secret: "s" });
|
||||
storeCreateSession({});
|
||||
storeCreateWorkItem({ environmentId: "env1", sessionId: "ses1", secret: "s" });
|
||||
describe('storeReset', () => {
|
||||
test('clears all data', () => {
|
||||
storeCreateUser('alice')
|
||||
storeCreateEnvironment({ secret: 's' })
|
||||
storeCreateSession({})
|
||||
storeCreateWorkItem({
|
||||
environmentId: 'env1',
|
||||
sessionId: 'ses1',
|
||||
secret: 's',
|
||||
})
|
||||
|
||||
storeReset();
|
||||
storeReset()
|
||||
|
||||
expect(storeGetUser("alice")).toBeUndefined();
|
||||
expect(storeListActiveEnvironments()).toHaveLength(0);
|
||||
expect(storeListSessions()).toHaveLength(0);
|
||||
expect(storeGetPendingWorkItem("env1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(storeGetUser('alice')).toBeUndefined()
|
||||
expect(storeListActiveEnvironments()).toHaveLength(0)
|
||||
expect(storeListSessions()).toHaveLength(0)
|
||||
expect(storeGetPendingWorkItem('env1')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
|
||||
const { normalizePayload } = await import("../services/transport");
|
||||
const { normalizePayload } = await import('../services/transport')
|
||||
|
||||
// extractContent is not exported; we test it via normalizePayload's content field
|
||||
|
||||
@@ -8,181 +8,190 @@ const { normalizePayload } = await import("../services/transport");
|
||||
// extractContent (via normalizePayload content field)
|
||||
// =============================================================================
|
||||
|
||||
describe("extractContent", () => {
|
||||
test("returns empty string for null payload", () => {
|
||||
const result = normalizePayload("assistant", null);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
describe('extractContent', () => {
|
||||
test('returns empty string for null payload', () => {
|
||||
const result = normalizePayload('assistant', null)
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
test("returns empty string for undefined payload", () => {
|
||||
const result = normalizePayload("assistant", undefined);
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
test('returns empty string for undefined payload', () => {
|
||||
const result = normalizePayload('assistant', undefined)
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
test("returns the string for string payload", () => {
|
||||
const result = normalizePayload("assistant", "hello world");
|
||||
expect(result.content).toBe("hello world");
|
||||
});
|
||||
test('returns the string for string payload', () => {
|
||||
const result = normalizePayload('assistant', 'hello world')
|
||||
expect(result.content).toBe('hello world')
|
||||
})
|
||||
|
||||
test("extracts content field from object payload", () => {
|
||||
const result = normalizePayload("assistant", { content: "direct content" });
|
||||
expect(result.content).toBe("direct content");
|
||||
});
|
||||
test('extracts content field from object payload', () => {
|
||||
const result = normalizePayload('assistant', { content: 'direct content' })
|
||||
expect(result.content).toBe('direct content')
|
||||
})
|
||||
|
||||
test("extracts message.content string from object payload", () => {
|
||||
const result = normalizePayload("assistant", { message: { content: "msg content" } });
|
||||
expect(result.content).toBe("msg content");
|
||||
});
|
||||
test('extracts message.content string from object payload', () => {
|
||||
const result = normalizePayload('assistant', {
|
||||
message: { content: 'msg content' },
|
||||
})
|
||||
expect(result.content).toBe('msg content')
|
||||
})
|
||||
|
||||
test("extracts text blocks from message.content array", () => {
|
||||
test('extracts text blocks from message.content array', () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "text", text: "Hello " },
|
||||
{ type: "text", text: "World" },
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{ type: 'text', text: 'World' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.content).toBe("Hello World");
|
||||
});
|
||||
}
|
||||
const result = normalizePayload('assistant', payload)
|
||||
expect(result.content).toBe('Hello World')
|
||||
})
|
||||
|
||||
test("ignores non-text blocks in message.content array", () => {
|
||||
test('ignores non-text blocks in message.content array', () => {
|
||||
const payload = {
|
||||
message: {
|
||||
content: [
|
||||
{ type: "image", url: "http://example.com/img.png" },
|
||||
{ type: "text", text: "only this" },
|
||||
{ type: 'image', url: 'http://example.com/img.png' },
|
||||
{ type: 'text', text: 'only this' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.content).toBe("only this");
|
||||
});
|
||||
}
|
||||
const result = normalizePayload('assistant', payload)
|
||||
expect(result.content).toBe('only this')
|
||||
})
|
||||
|
||||
test("returns empty string when no extractable content", () => {
|
||||
const result = normalizePayload("assistant", { foo: "bar" });
|
||||
expect(result.content).toBe("");
|
||||
});
|
||||
test('returns empty string when no extractable content', () => {
|
||||
const result = normalizePayload('assistant', { foo: 'bar' })
|
||||
expect(result.content).toBe('')
|
||||
})
|
||||
|
||||
test("prefers direct content over message.content", () => {
|
||||
const result = normalizePayload("assistant", { content: "direct", message: { content: "nested" } });
|
||||
expect(result.content).toBe("direct");
|
||||
});
|
||||
});
|
||||
test('prefers direct content over message.content', () => {
|
||||
const result = normalizePayload('assistant', {
|
||||
content: 'direct',
|
||||
message: { content: 'nested' },
|
||||
})
|
||||
expect(result.content).toBe('direct')
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// normalizePayload — field preservation
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizePayload — field preservation", () => {
|
||||
test("preserves raw payload", () => {
|
||||
const payload = { content: "test", extra: true };
|
||||
const result = normalizePayload("assistant", payload);
|
||||
expect(result.raw).toBe(payload);
|
||||
});
|
||||
describe('normalizePayload — field preservation', () => {
|
||||
test('preserves raw payload', () => {
|
||||
const payload = { content: 'test', extra: true }
|
||||
const result = normalizePayload('assistant', payload)
|
||||
expect(result.raw).toBe(payload)
|
||||
})
|
||||
|
||||
test("preserves uuid field", () => {
|
||||
const result = normalizePayload("assistant", { uuid: "u-123" });
|
||||
expect(result.uuid).toBe("u-123");
|
||||
});
|
||||
test('preserves uuid field', () => {
|
||||
const result = normalizePayload('assistant', { uuid: 'u-123' })
|
||||
expect(result.uuid).toBe('u-123')
|
||||
})
|
||||
|
||||
test("does not preserve uuid when empty string", () => {
|
||||
const result = normalizePayload("assistant", { uuid: "" });
|
||||
expect(result.uuid).toBeUndefined();
|
||||
});
|
||||
test('does not preserve uuid when empty string', () => {
|
||||
const result = normalizePayload('assistant', { uuid: '' })
|
||||
expect(result.uuid).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves isSynthetic boolean", () => {
|
||||
const result = normalizePayload("assistant", { isSynthetic: true });
|
||||
expect(result.isSynthetic).toBe(true);
|
||||
});
|
||||
test('preserves isSynthetic boolean', () => {
|
||||
const result = normalizePayload('assistant', { isSynthetic: true })
|
||||
expect(result.isSynthetic).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves status string", () => {
|
||||
const result = normalizePayload("assistant", { status: "running" });
|
||||
expect(result.status).toBe("running");
|
||||
});
|
||||
test('preserves status string', () => {
|
||||
const result = normalizePayload('assistant', { status: 'running' })
|
||||
expect(result.status).toBe('running')
|
||||
})
|
||||
|
||||
test("preserves subtype string", () => {
|
||||
const result = normalizePayload("assistant", { subtype: "progress" });
|
||||
expect(result.subtype).toBe("progress");
|
||||
});
|
||||
test('preserves subtype string', () => {
|
||||
const result = normalizePayload('assistant', { subtype: 'progress' })
|
||||
expect(result.subtype).toBe('progress')
|
||||
})
|
||||
|
||||
test("preserves tool_name from tool_name field", () => {
|
||||
const result = normalizePayload("tool", { tool_name: "bash" });
|
||||
expect(result.tool_name).toBe("bash");
|
||||
});
|
||||
test('preserves tool_name from tool_name field', () => {
|
||||
const result = normalizePayload('tool', { tool_name: 'bash' })
|
||||
expect(result.tool_name).toBe('bash')
|
||||
})
|
||||
|
||||
test("preserves tool_name from name field", () => {
|
||||
const result = normalizePayload("tool", { name: "read" });
|
||||
expect(result.tool_name).toBe("read");
|
||||
});
|
||||
test('preserves tool_name from name field', () => {
|
||||
const result = normalizePayload('tool', { name: 'read' })
|
||||
expect(result.tool_name).toBe('read')
|
||||
})
|
||||
|
||||
test("preserves tool_input from tool_input field", () => {
|
||||
const input = { command: "ls" };
|
||||
const result = normalizePayload("tool", { tool_input: input });
|
||||
expect(result.tool_input).toEqual(input);
|
||||
});
|
||||
test('preserves tool_input from tool_input field', () => {
|
||||
const input = { command: 'ls' }
|
||||
const result = normalizePayload('tool', { tool_input: input })
|
||||
expect(result.tool_input).toEqual(input)
|
||||
})
|
||||
|
||||
test("preserves tool_input from input field", () => {
|
||||
const input = { path: "/tmp" };
|
||||
const result = normalizePayload("tool", { input });
|
||||
expect(result.tool_input).toEqual(input);
|
||||
});
|
||||
test('preserves tool_input from input field', () => {
|
||||
const input = { path: '/tmp' }
|
||||
const result = normalizePayload('tool', { input })
|
||||
expect(result.tool_input).toEqual(input)
|
||||
})
|
||||
|
||||
test("preserves request_id", () => {
|
||||
const result = normalizePayload("permission", { request_id: "req-1" });
|
||||
expect(result.request_id).toBe("req-1");
|
||||
});
|
||||
test('preserves request_id', () => {
|
||||
const result = normalizePayload('permission', { request_id: 'req-1' })
|
||||
expect(result.request_id).toBe('req-1')
|
||||
})
|
||||
|
||||
test("preserves request object", () => {
|
||||
const req = { subtype: "permission" };
|
||||
const result = normalizePayload("permission", { request: req });
|
||||
expect(result.request).toEqual(req);
|
||||
});
|
||||
test('preserves request object', () => {
|
||||
const req = { subtype: 'permission' }
|
||||
const result = normalizePayload('permission', { request: req })
|
||||
expect(result.request).toEqual(req)
|
||||
})
|
||||
|
||||
test("preserves approved field", () => {
|
||||
const result = normalizePayload("permission", { approved: true });
|
||||
expect(result.approved).toBe(true);
|
||||
});
|
||||
test('preserves approved field', () => {
|
||||
const result = normalizePayload('permission', { approved: true })
|
||||
expect(result.approved).toBe(true)
|
||||
})
|
||||
|
||||
test("preserves updated_input", () => {
|
||||
const input = { command: "rm -rf" };
|
||||
const result = normalizePayload("permission", { updated_input: input });
|
||||
expect(result.updated_input).toEqual(input);
|
||||
});
|
||||
test('preserves updated_input', () => {
|
||||
const input = { command: 'rm -rf' }
|
||||
const result = normalizePayload('permission', { updated_input: input })
|
||||
expect(result.updated_input).toEqual(input)
|
||||
})
|
||||
|
||||
test("preserves message field for backward compat", () => {
|
||||
const msg = { role: "user", content: "hi" };
|
||||
const result = normalizePayload("assistant", { message: msg });
|
||||
expect(result.message).toEqual(msg);
|
||||
});
|
||||
});
|
||||
test('preserves message field for backward compat', () => {
|
||||
const msg = { role: 'user', content: 'hi' }
|
||||
const result = normalizePayload('assistant', { message: msg })
|
||||
expect(result.message).toEqual(msg)
|
||||
})
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// normalizePayload — task_state special handling
|
||||
// =============================================================================
|
||||
|
||||
describe("normalizePayload — task_state type", () => {
|
||||
test("preserves task_list_id (snake_case)", () => {
|
||||
const result = normalizePayload("task_state", { task_list_id: "tl-1" });
|
||||
expect(result.task_list_id).toBe("tl-1");
|
||||
});
|
||||
describe('normalizePayload — task_state type', () => {
|
||||
test('preserves task_list_id (snake_case)', () => {
|
||||
const result = normalizePayload('task_state', { task_list_id: 'tl-1' })
|
||||
expect(result.task_list_id).toBe('tl-1')
|
||||
})
|
||||
|
||||
test("preserves taskListId (camelCase)", () => {
|
||||
const result = normalizePayload("task_state", { taskListId: "tl-2" });
|
||||
expect(result.taskListId).toBe("tl-2");
|
||||
});
|
||||
test('preserves taskListId (camelCase)', () => {
|
||||
const result = normalizePayload('task_state', { taskListId: 'tl-2' })
|
||||
expect(result.taskListId).toBe('tl-2')
|
||||
})
|
||||
|
||||
test("preserves tasks array", () => {
|
||||
const tasks = [{ id: "t1", title: "Task 1" }];
|
||||
const result = normalizePayload("task_state", { tasks });
|
||||
expect(result.tasks).toEqual(tasks);
|
||||
});
|
||||
test('preserves tasks array', () => {
|
||||
const tasks = [{ id: 't1', title: 'Task 1' }]
|
||||
const result = normalizePayload('task_state', { tasks })
|
||||
expect(result.tasks).toEqual(tasks)
|
||||
})
|
||||
|
||||
test("does not preserve task fields for non-task_state type", () => {
|
||||
const result = normalizePayload("assistant", { task_list_id: "tl-1", taskListId: "tl-2", tasks: [] });
|
||||
expect(result.task_list_id).toBeUndefined();
|
||||
expect(result.taskListId).toBeUndefined();
|
||||
expect(result.tasks).toBeUndefined();
|
||||
});
|
||||
});
|
||||
test('does not preserve task fields for non-task_state type', () => {
|
||||
const result = normalizePayload('assistant', {
|
||||
task_list_id: 'tl-1',
|
||||
taskListId: 'tl-2',
|
||||
tasks: [],
|
||||
})
|
||||
expect(result.task_list_id).toBeUndefined()
|
||||
expect(result.taskListId).toBeUndefined()
|
||||
expect(result.tasks).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test";
|
||||
import { describe, test, expect, beforeEach, mock } from 'bun:test'
|
||||
|
||||
// Mock config before imports
|
||||
const mockConfig = {
|
||||
port: 3000,
|
||||
host: "0.0.0.0",
|
||||
apiKeys: ["test-api-key"],
|
||||
baseUrl: "http://localhost:3000",
|
||||
host: '0.0.0.0',
|
||||
apiKeys: ['test-api-key'],
|
||||
baseUrl: 'http://localhost:3000',
|
||||
pollTimeout: 1, // Short timeout for tests
|
||||
heartbeatInterval: 20,
|
||||
jwtExpiresIn: 3600,
|
||||
@@ -13,14 +13,20 @@ const mockConfig = {
|
||||
webCorsOrigins: [],
|
||||
wsIdleTimeout: 30,
|
||||
wsKeepaliveInterval: 20,
|
||||
};
|
||||
}
|
||||
|
||||
mock.module("../config", () => ({
|
||||
mock.module('../config', () => ({
|
||||
config: mockConfig,
|
||||
getBaseUrl: () => "http://localhost:3000",
|
||||
}));
|
||||
getBaseUrl: () => 'http://localhost:3000',
|
||||
}))
|
||||
|
||||
import { storeReset, storeCreateEnvironment, storeCreateSession, storeGetWorkItem, storeGetPendingWorkItem } from "../store";
|
||||
import {
|
||||
storeReset,
|
||||
storeCreateEnvironment,
|
||||
storeCreateSession,
|
||||
storeGetWorkItem,
|
||||
storeGetPendingWorkItem,
|
||||
} from '../store'
|
||||
import {
|
||||
createWorkItem,
|
||||
pollWork,
|
||||
@@ -28,132 +34,138 @@ import {
|
||||
stopWork,
|
||||
heartbeatWork,
|
||||
reconnectWorkForEnvironment,
|
||||
} from "../services/work-dispatch";
|
||||
} from '../services/work-dispatch'
|
||||
|
||||
describe("Work Dispatch", () => {
|
||||
let envId: string;
|
||||
let sessionId: string;
|
||||
describe('Work Dispatch', () => {
|
||||
let envId: string
|
||||
let sessionId: string
|
||||
|
||||
beforeEach(() => {
|
||||
storeReset();
|
||||
const env = storeCreateEnvironment({ secret: "s" });
|
||||
envId = env.id;
|
||||
const session = storeCreateSession({ environmentId: envId });
|
||||
sessionId = session.id;
|
||||
});
|
||||
storeReset()
|
||||
const env = storeCreateEnvironment({ secret: 's' })
|
||||
envId = env.id
|
||||
const session = storeCreateSession({ environmentId: envId })
|
||||
sessionId = session.id
|
||||
})
|
||||
|
||||
describe("createWorkItem", () => {
|
||||
test("creates work item for active environment", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
expect(workId).toMatch(/^work_/);
|
||||
const item = storeGetWorkItem(workId);
|
||||
expect(item?.state).toBe("pending");
|
||||
expect(item?.sessionId).toBe(sessionId);
|
||||
});
|
||||
describe('createWorkItem', () => {
|
||||
test('creates work item for active environment', async () => {
|
||||
const workId = await createWorkItem(envId, sessionId)
|
||||
expect(workId).toMatch(/^work_/)
|
||||
const item = storeGetWorkItem(workId)
|
||||
expect(item?.state).toBe('pending')
|
||||
expect(item?.sessionId).toBe(sessionId)
|
||||
})
|
||||
|
||||
test("throws for non-existent environment", async () => {
|
||||
await expect(createWorkItem("env_no", sessionId)).rejects.toThrow("not found");
|
||||
});
|
||||
test('throws for non-existent environment', async () => {
|
||||
await expect(createWorkItem('env_no', sessionId)).rejects.toThrow(
|
||||
'not found',
|
||||
)
|
||||
})
|
||||
|
||||
test("throws for inactive environment", async () => {
|
||||
const inactiveEnv = storeCreateEnvironment({ secret: "s2" });
|
||||
test('throws for inactive environment', async () => {
|
||||
const inactiveEnv = storeCreateEnvironment({ secret: 's2' })
|
||||
// Manually set status to deregistered
|
||||
const { storeUpdateEnvironment } = await import("../store");
|
||||
storeUpdateEnvironment(inactiveEnv.id, { status: "deregistered" });
|
||||
await expect(createWorkItem(inactiveEnv.id, sessionId)).rejects.toThrow("not active");
|
||||
});
|
||||
const { storeUpdateEnvironment } = await import('../store')
|
||||
storeUpdateEnvironment(inactiveEnv.id, { status: 'deregistered' })
|
||||
await expect(createWorkItem(inactiveEnv.id, sessionId)).rejects.toThrow(
|
||||
'not active',
|
||||
)
|
||||
})
|
||||
|
||||
test("encodes work secret as base64 JSON", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
const item = storeGetWorkItem(workId);
|
||||
const decoded = JSON.parse(Buffer.from(item!.secret, "base64url").toString());
|
||||
expect(decoded.version).toBe(1);
|
||||
expect(decoded.session_ingress_token).toBe("test-api-key");
|
||||
expect(decoded.api_base_url).toBe("http://localhost:3000");
|
||||
});
|
||||
});
|
||||
test('encodes work secret as base64 JSON', async () => {
|
||||
const workId = await createWorkItem(envId, sessionId)
|
||||
const item = storeGetWorkItem(workId)
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(item!.secret, 'base64url').toString(),
|
||||
)
|
||||
expect(decoded.version).toBe(1)
|
||||
expect(decoded.session_ingress_token).toBe('test-api-key')
|
||||
expect(decoded.api_base_url).toBe('http://localhost:3000')
|
||||
})
|
||||
})
|
||||
|
||||
describe("pollWork", () => {
|
||||
test("returns null when no work available (timeout)", async () => {
|
||||
const result = await pollWork(envId, 0.1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
describe('pollWork', () => {
|
||||
test('returns null when no work available (timeout)', async () => {
|
||||
const result = await pollWork(envId, 0.1)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns pending work and marks as dispatched", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
const result = await pollWork(envId, 1);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(workId);
|
||||
expect(result!.state).toBe("dispatched");
|
||||
expect(result!.data.type).toBe("session");
|
||||
expect(result!.data.id).toBe(sessionId);
|
||||
test('returns pending work and marks as dispatched', async () => {
|
||||
const workId = await createWorkItem(envId, sessionId)
|
||||
const result = await pollWork(envId, 1)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.id).toBe(workId)
|
||||
expect(result!.state).toBe('dispatched')
|
||||
expect(result!.data.type).toBe('session')
|
||||
expect(result!.data.id).toBe(sessionId)
|
||||
// Work should no longer be pending
|
||||
expect(storeGetPendingWorkItem(envId)).toBeUndefined();
|
||||
});
|
||||
expect(storeGetPendingWorkItem(envId)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not return work for different environment", async () => {
|
||||
const env2 = storeCreateEnvironment({ secret: "s2" });
|
||||
await createWorkItem(envId, sessionId);
|
||||
const result = await pollWork(env2.id, 0.1);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
test('does not return work for different environment', async () => {
|
||||
const env2 = storeCreateEnvironment({ secret: 's2' })
|
||||
await createWorkItem(envId, sessionId)
|
||||
const result = await pollWork(env2.id, 0.1)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ackWork", () => {
|
||||
test("marks work as acked", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
ackWork(workId);
|
||||
expect(storeGetWorkItem(workId)?.state).toBe("acked");
|
||||
});
|
||||
});
|
||||
describe('ackWork', () => {
|
||||
test('marks work as acked', async () => {
|
||||
const workId = await createWorkItem(envId, sessionId)
|
||||
ackWork(workId)
|
||||
expect(storeGetWorkItem(workId)?.state).toBe('acked')
|
||||
})
|
||||
})
|
||||
|
||||
describe("stopWork", () => {
|
||||
test("marks work as completed", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
stopWork(workId);
|
||||
expect(storeGetWorkItem(workId)?.state).toBe("completed");
|
||||
});
|
||||
});
|
||||
describe('stopWork', () => {
|
||||
test('marks work as completed', async () => {
|
||||
const workId = await createWorkItem(envId, sessionId)
|
||||
stopWork(workId)
|
||||
expect(storeGetWorkItem(workId)?.state).toBe('completed')
|
||||
})
|
||||
})
|
||||
|
||||
describe("heartbeatWork", () => {
|
||||
test("extends lease and returns heartbeat info", async () => {
|
||||
const workId = await createWorkItem(envId, sessionId);
|
||||
const result = heartbeatWork(workId);
|
||||
expect(result.lease_extended).toBe(true);
|
||||
expect(result.ttl_seconds).toBe(40); // heartbeatInterval * 2
|
||||
expect(result.last_heartbeat).toBeTruthy();
|
||||
});
|
||||
describe('heartbeatWork', () => {
|
||||
test('extends lease and returns heartbeat info', async () => {
|
||||
const workId = await createWorkItem(envId, sessionId)
|
||||
const result = heartbeatWork(workId)
|
||||
expect(result.lease_extended).toBe(true)
|
||||
expect(result.ttl_seconds).toBe(40) // heartbeatInterval * 2
|
||||
expect(result.last_heartbeat).toBeTruthy()
|
||||
})
|
||||
|
||||
test("returns default state for non-existent work", async () => {
|
||||
const result = heartbeatWork("work_no");
|
||||
expect(result.state).toBe("acked");
|
||||
});
|
||||
});
|
||||
test('returns default state for non-existent work', async () => {
|
||||
const result = heartbeatWork('work_no')
|
||||
expect(result.state).toBe('acked')
|
||||
})
|
||||
})
|
||||
|
||||
describe("reconnectWorkForEnvironment", () => {
|
||||
test("creates work items for idle sessions in environment", async () => {
|
||||
describe('reconnectWorkForEnvironment', () => {
|
||||
test('creates work items for idle sessions in environment', async () => {
|
||||
// Create another idle session
|
||||
storeCreateSession({ environmentId: envId });
|
||||
const workIds = await reconnectWorkForEnvironment(envId);
|
||||
expect(workIds).toHaveLength(2);
|
||||
storeCreateSession({ environmentId: envId })
|
||||
const workIds = await reconnectWorkForEnvironment(envId)
|
||||
expect(workIds).toHaveLength(2)
|
||||
for (const id of workIds) {
|
||||
expect(storeGetWorkItem(id)?.state).toBe("pending");
|
||||
expect(storeGetWorkItem(id)?.state).toBe('pending')
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
test("skips non-idle sessions", async () => {
|
||||
const activeSession = storeCreateSession({ environmentId: envId });
|
||||
const { storeUpdateSession } = await import("../store");
|
||||
storeUpdateSession(activeSession.id, { status: "active" });
|
||||
const workIds = await reconnectWorkForEnvironment(envId);
|
||||
test('skips non-idle sessions', async () => {
|
||||
const activeSession = storeCreateSession({ environmentId: envId })
|
||||
const { storeUpdateSession } = await import('../store')
|
||||
storeUpdateSession(activeSession.id, { status: 'active' })
|
||||
const workIds = await reconnectWorkForEnvironment(envId)
|
||||
// Only the original idle session should get work
|
||||
expect(workIds).toHaveLength(1);
|
||||
});
|
||||
expect(workIds).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("returns empty for environment with no sessions", async () => {
|
||||
const emptyEnv = storeCreateEnvironment({ secret: "s_empty" });
|
||||
const workIds = await reconnectWorkForEnvironment(emptyEnv.id);
|
||||
expect(workIds).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
test('returns empty for environment with no sessions', async () => {
|
||||
const emptyEnv = storeCreateEnvironment({ secret: 's_empty' })
|
||||
const workIds = await reconnectWorkForEnvironment(emptyEnv.id)
|
||||
expect(workIds).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,17 @@
|
||||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
import { config } from "../config";
|
||||
import { createHash, timingSafeEqual } from 'node:crypto'
|
||||
import { config } from '../config'
|
||||
|
||||
function sha256(value: string): Buffer {
|
||||
return createHash("sha256").update(value).digest();
|
||||
return createHash('sha256').update(value).digest()
|
||||
}
|
||||
|
||||
/** Validate a raw API key token string */
|
||||
export function validateApiKey(token: string | undefined): boolean {
|
||||
if (!token) return false;
|
||||
const tokenHash = sha256(token);
|
||||
return config.apiKeys.some((key) => timingSafeEqual(tokenHash, sha256(key)));
|
||||
if (!token) return false
|
||||
const tokenHash = sha256(token)
|
||||
return config.apiKeys.some(key => timingSafeEqual(tokenHash, sha256(key)))
|
||||
}
|
||||
|
||||
export function hashApiKey(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
return createHash('sha256').update(key).digest('hex')
|
||||
}
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { config } from "../config";
|
||||
import { config } from '../config'
|
||||
|
||||
function originFromUrl(rawUrl: string): string | undefined {
|
||||
try {
|
||||
return new URL(rawUrl).origin;
|
||||
return new URL(rawUrl).origin
|
||||
} catch {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllowedWebCorsOrigins(): string[] {
|
||||
const origins = new Set<string>(config.webCorsOrigins);
|
||||
const origins = new Set<string>(config.webCorsOrigins)
|
||||
|
||||
const baseOrigin = config.baseUrl ? originFromUrl(config.baseUrl) : undefined;
|
||||
const baseOrigin = config.baseUrl ? originFromUrl(config.baseUrl) : undefined
|
||||
if (baseOrigin) {
|
||||
origins.add(baseOrigin);
|
||||
origins.add(baseOrigin)
|
||||
}
|
||||
|
||||
origins.add(`http://localhost:${config.port}`);
|
||||
origins.add(`http://127.0.0.1:${config.port}`);
|
||||
origins.add(`http://localhost:${config.port}`)
|
||||
origins.add(`http://127.0.0.1:${config.port}`)
|
||||
|
||||
return [...origins];
|
||||
return [...origins]
|
||||
}
|
||||
|
||||
export function resolveWebCorsOrigin(origin: string): string | undefined {
|
||||
return getAllowedWebCorsOrigins().includes(origin) ? origin : undefined;
|
||||
return getAllowedWebCorsOrigins().includes(origin) ? origin : undefined
|
||||
}
|
||||
|
||||
export const webCorsOptions = {
|
||||
origin: resolveWebCorsOrigin,
|
||||
allowHeaders: ["Authorization", "Content-Type", "X-UUID"],
|
||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||
allowHeaders: ['Authorization', 'Content-Type', 'X-UUID'],
|
||||
allowMethods: ['GET', 'POST', 'OPTIONS'],
|
||||
credentials: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Lightweight JWT implementation using HMAC-SHA256.
|
||||
@@ -9,29 +9,29 @@ import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
*/
|
||||
|
||||
interface JwtPayload {
|
||||
session_id: string;
|
||||
role: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
session_id: string
|
||||
role: string
|
||||
iat: number
|
||||
exp: number
|
||||
}
|
||||
|
||||
function base64url(data: string | Buffer): string {
|
||||
return Buffer.from(data as unknown as ArrayLike<number>)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function base64urlDecode(str: string): string {
|
||||
const padded = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Buffer.from(padded, "base64").toString("utf-8");
|
||||
const padded = str.replace(/-/g, '+').replace(/_/g, '/')
|
||||
return Buffer.from(padded, 'base64').toString('utf-8')
|
||||
}
|
||||
|
||||
function getSigningKey(): string {
|
||||
const key = process.env.RCS_API_KEYS?.split(",").filter(Boolean)[0];
|
||||
if (!key) throw new Error("No API key configured for JWT signing");
|
||||
return key;
|
||||
const key = process.env.RCS_API_KEYS?.split(',').filter(Boolean)[0]
|
||||
if (!key) throw new Error('No API key configured for JWT signing')
|
||||
return key
|
||||
}
|
||||
|
||||
/** Generate a JWT for worker authentication. */
|
||||
@@ -39,23 +39,23 @@ export function generateWorkerJwt(
|
||||
sessionId: string,
|
||||
expiresInSeconds: number,
|
||||
): string {
|
||||
const header = { alg: "HS256", typ: "JWT" };
|
||||
const header = { alg: 'HS256', typ: 'JWT' }
|
||||
const payload: JwtPayload = {
|
||||
session_id: sessionId,
|
||||
role: "worker",
|
||||
role: 'worker',
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
const headerB64 = base64url(JSON.stringify(header));
|
||||
const payloadB64 = base64url(JSON.stringify(payload));
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const headerB64 = base64url(JSON.stringify(header))
|
||||
const payloadB64 = base64url(JSON.stringify(payload))
|
||||
const signingInput = `${headerB64}.${payloadB64}`
|
||||
|
||||
const signature = createHmac("sha256", getSigningKey())
|
||||
const signature = createHmac('sha256', getSigningKey())
|
||||
.update(signingInput)
|
||||
.digest();
|
||||
.digest()
|
||||
|
||||
return `${signingInput}.${base64url(signature)}`;
|
||||
return `${signingInput}.${base64url(signature)}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,30 +63,30 @@ export function generateWorkerJwt(
|
||||
* Uses timing-safe comparison to prevent timing attacks.
|
||||
*/
|
||||
export function verifyWorkerJwt(token: string): JwtPayload | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) return null
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const [headerB64, payloadB64, signatureB64] = parts
|
||||
|
||||
// Verify signature
|
||||
const signingInput = `${headerB64}.${payloadB64}`;
|
||||
const expectedSig = createHmac("sha256", getSigningKey())
|
||||
const signingInput = `${headerB64}.${payloadB64}`
|
||||
const expectedSig = createHmac('sha256', getSigningKey())
|
||||
.update(signingInput)
|
||||
.digest();
|
||||
.digest()
|
||||
const actualSig = Buffer.from(
|
||||
signatureB64.replace(/-/g, "+").replace(/_/g, "/"),
|
||||
"base64",
|
||||
);
|
||||
signatureB64.replace(/-/g, '+').replace(/_/g, '/'),
|
||||
'base64',
|
||||
)
|
||||
|
||||
if (expectedSig.length !== actualSig.length) return null;
|
||||
if (!timingSafeEqual(expectedSig, actualSig)) return null;
|
||||
if (expectedSig.length !== actualSig.length) return null
|
||||
if (!timingSafeEqual(expectedSig, actualSig)) return null
|
||||
|
||||
// Decode payload
|
||||
try {
|
||||
const payload: JwtPayload = JSON.parse(base64urlDecode(payloadB64));
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null;
|
||||
return payload;
|
||||
const payload: JwtPayload = JSON.parse(base64urlDecode(payloadB64))
|
||||
if (payload.exp < Math.floor(Date.now() / 1000)) return null
|
||||
return payload
|
||||
} catch {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,58 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { validateApiKey } from "./api-key";
|
||||
import { verifyWorkerJwt } from "./jwt";
|
||||
import { resolveToken } from "./token";
|
||||
import type { Context, Next } from 'hono'
|
||||
import { validateApiKey } from './api-key'
|
||||
import { verifyWorkerJwt } from './jwt'
|
||||
import { resolveToken } from './token'
|
||||
|
||||
const WS_AUTH_PROTOCOL_PREFIX = "rcs.auth.";
|
||||
const WS_AUTH_PROTOCOL_PREFIX = 'rcs.auth.'
|
||||
|
||||
/** Encode a bearer token for WebSocket clients that cannot send auth headers. */
|
||||
export function encodeWebSocketAuthProtocol(token: string): string {
|
||||
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, "utf8").toString("base64url")}`;
|
||||
return `${WS_AUTH_PROTOCOL_PREFIX}${Buffer.from(token, 'utf8').toString('base64url')}`
|
||||
}
|
||||
|
||||
function decodeWebSocketAuthProtocol(protocolHeader: string | undefined): string | undefined {
|
||||
function decodeWebSocketAuthProtocol(
|
||||
protocolHeader: string | undefined,
|
||||
): string | undefined {
|
||||
if (!protocolHeader) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const protocol of protocolHeader.split(",")) {
|
||||
const trimmed = protocol.trim();
|
||||
for (const protocol of protocolHeader.split(',')) {
|
||||
const trimmed = protocol.trim()
|
||||
if (!trimmed.startsWith(WS_AUTH_PROTOCOL_PREFIX)) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length);
|
||||
const encoded = trimmed.slice(WS_AUTH_PROTOCOL_PREFIX.length)
|
||||
if (!encoded) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const token = Buffer.from(encoded, "base64url").toString("utf8");
|
||||
return token.length > 0 ? token : undefined;
|
||||
const token = Buffer.from(encoded, 'base64url').toString('utf8')
|
||||
return token.length > 0 ? token : undefined
|
||||
} catch {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Extract a Bearer token from the Authorization header only. */
|
||||
export function extractBearerToken(c: Context): string | undefined {
|
||||
const authHeader = c.req.header("Authorization");
|
||||
return authHeader?.startsWith("Bearer ") ? authHeader.slice("Bearer ".length) : undefined;
|
||||
const authHeader = c.req.header('Authorization')
|
||||
return authHeader?.startsWith('Bearer ')
|
||||
? authHeader.slice('Bearer '.length)
|
||||
: undefined
|
||||
}
|
||||
|
||||
/** Extract auth for WebSocket upgrades without putting secrets in query strings. */
|
||||
export function extractWebSocketAuthToken(c: Context): string | undefined {
|
||||
return extractBearerToken(c) ?? decodeWebSocketAuthProtocol(c.req.header("Sec-WebSocket-Protocol"));
|
||||
return (
|
||||
extractBearerToken(c) ??
|
||||
decodeWebSocketAuthProtocol(c.req.header('Sec-WebSocket-Protocol'))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,28 +62,33 @@ export function extractWebSocketAuthToken(c: Context): string | undefined {
|
||||
* 2. **API Key mode** (CLI bridge): Valid API key + X-Username header → username injected
|
||||
*/
|
||||
export async function apiKeyAuth(c: Context, next: Next) {
|
||||
const token = extractBearerToken(c);
|
||||
const token = extractBearerToken(c)
|
||||
|
||||
// Try token authentication (Web UI)
|
||||
const tokenUsername = resolveToken(token);
|
||||
const tokenUsername = resolveToken(token)
|
||||
if (tokenUsername) {
|
||||
c.set("username", tokenUsername);
|
||||
await next();
|
||||
return;
|
||||
c.set('username', tokenUsername)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
// Try API Key authentication (CLI bridge)
|
||||
if (validateApiKey(token)) {
|
||||
// Extract username from X-Username header or ?username= query param
|
||||
const username = c.req.header("X-Username") || c.req.query("username");
|
||||
const username = c.req.header('X-Username') || c.req.query('username')
|
||||
if (username) {
|
||||
c.set("username", username);
|
||||
c.set('username', username)
|
||||
}
|
||||
await next();
|
||||
return;
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
return c.json({ error: { type: "unauthorized", message: "Invalid or missing auth token" } }, 401);
|
||||
return c.json(
|
||||
{
|
||||
error: { type: 'unauthorized', message: 'Invalid or missing auth token' },
|
||||
},
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,43 +99,57 @@ export async function apiKeyAuth(c: Context, next: Next) {
|
||||
* downstream handlers to inspect session_id if needed.
|
||||
*/
|
||||
export async function sessionIngressAuth(c: Context, next: Next) {
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
const token = extractWebSocketAuthToken(c)
|
||||
|
||||
if (!token) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth token" } }, 401);
|
||||
return c.json(
|
||||
{ error: { type: 'unauthorized', message: 'Missing auth token' } },
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
// Try API key first (backward compatible)
|
||||
if (validateApiKey(token)) {
|
||||
await next();
|
||||
return;
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
// Try JWT verification — validate session_id matches route param
|
||||
const payload = verifyWorkerJwt(token);
|
||||
const payload = verifyWorkerJwt(token)
|
||||
if (payload) {
|
||||
const routeSessionId = c.req.param("id") || c.req.param("sessionId");
|
||||
const routeSessionId = c.req.param('id') || c.req.param('sessionId')
|
||||
if (routeSessionId && payload.session_id !== routeSessionId) {
|
||||
return c.json({ error: { type: "forbidden", message: "JWT session_id does not match target session" } }, 403);
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
type: 'forbidden',
|
||||
message: 'JWT session_id does not match target session',
|
||||
},
|
||||
},
|
||||
403,
|
||||
)
|
||||
}
|
||||
c.set("jwtPayload", payload);
|
||||
await next();
|
||||
return;
|
||||
c.set('jwtPayload', payload)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
return c.json({ error: { type: "unauthorized", message: "Invalid API key or JWT" } }, 401);
|
||||
return c.json(
|
||||
{ error: { type: 'unauthorized', message: 'Invalid API key or JWT' } },
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
/** Accept CLI headers but don't validate them */
|
||||
export async function acceptCliHeaders(c: Context, next: Next) {
|
||||
await next();
|
||||
await next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract UUID from request — query param ?uuid= or header X-UUID
|
||||
*/
|
||||
export function getUuidFromRequest(c: Context): string | undefined {
|
||||
return c.req.query("uuid") || c.req.header("X-UUID");
|
||||
return c.req.query('uuid') || c.req.header('X-UUID')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,20 +158,23 @@ export function getUuidFromRequest(c: Context): string | undefined {
|
||||
*/
|
||||
export async function uuidAuth(c: Context, next: Next) {
|
||||
// Try API key auth via Authorization header
|
||||
const bearer = extractBearerToken(c);
|
||||
const bearer = extractBearerToken(c)
|
||||
if (bearer && validateApiKey(bearer)) {
|
||||
// Valid API key — generate a stable UUID from the key for downstream use
|
||||
const uuid = getUuidFromRequest(c);
|
||||
c.set("uuid", uuid || bearer);
|
||||
await next();
|
||||
return;
|
||||
const uuid = getUuidFromRequest(c)
|
||||
c.set('uuid', uuid || bearer)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to UUID auth
|
||||
const uuid = getUuidFromRequest(c);
|
||||
const uuid = getUuidFromRequest(c)
|
||||
if (!uuid) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing UUID" } }, 401);
|
||||
return c.json(
|
||||
{ error: { type: 'unauthorized', message: 'Missing UUID' } },
|
||||
401,
|
||||
)
|
||||
}
|
||||
c.set("uuid", uuid);
|
||||
await next();
|
||||
c.set('uuid', uuid)
|
||||
await next()
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { storeCreateToken, storeGetUserByToken } from "../store";
|
||||
import { storeCreateToken, storeGetUserByToken } from '../store'
|
||||
|
||||
let tokenCounter = 0;
|
||||
let tokenCounter = 0
|
||||
|
||||
/** Generate a random session token and associate it with a user */
|
||||
export function issueToken(username: string): { token: string; expires_in: number } {
|
||||
export function issueToken(username: string): {
|
||||
token: string
|
||||
expires_in: number
|
||||
} {
|
||||
// Use crypto.getRandomValues for uniqueness
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const bytes = new Uint8Array(16)
|
||||
crypto.getRandomValues(bytes)
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
const token = `rct_${tokenCounter++}_${hex}`;
|
||||
storeCreateToken(username, token);
|
||||
return { token, expires_in: 86400 };
|
||||
.map(b => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
const token = `rct_${tokenCounter++}_${hex}`
|
||||
storeCreateToken(username, token)
|
||||
return { token, expires_in: 86400 }
|
||||
}
|
||||
|
||||
/** Resolve a token to a username. Returns null if invalid. */
|
||||
export function resolveToken(token: string | undefined): string | null {
|
||||
if (!token) return null;
|
||||
const entry = storeGetUserByToken(token);
|
||||
if (!entry) return null;
|
||||
return entry.username;
|
||||
if (!token) return null
|
||||
const entry = storeGetUserByToken(token)
|
||||
if (!entry) return null
|
||||
return entry.username
|
||||
}
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
export const config = {
|
||||
version: process.env.RCS_VERSION || "0.1.0",
|
||||
port: parseInt(process.env.RCS_PORT || "3000"),
|
||||
host: process.env.RCS_HOST || "0.0.0.0",
|
||||
apiKeys: (process.env.RCS_API_KEYS || "").split(",").filter(Boolean),
|
||||
baseUrl: process.env.RCS_BASE_URL || "",
|
||||
pollTimeout: parseInt(process.env.RCS_POLL_TIMEOUT || "8"),
|
||||
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || "20"),
|
||||
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || "3600"),
|
||||
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || "300"),
|
||||
webCorsOrigins: (process.env.RCS_WEB_CORS_ORIGINS || "")
|
||||
.split(",")
|
||||
.map((origin) => origin.trim())
|
||||
version: process.env.RCS_VERSION || '0.1.0',
|
||||
port: parseInt(process.env.RCS_PORT || '3000', 10),
|
||||
host: process.env.RCS_HOST || '0.0.0.0',
|
||||
apiKeys: (process.env.RCS_API_KEYS || '').split(',').filter(Boolean),
|
||||
baseUrl: process.env.RCS_BASE_URL || '',
|
||||
pollTimeout: parseInt(process.env.RCS_POLL_TIMEOUT || '8', 10),
|
||||
heartbeatInterval: parseInt(process.env.RCS_HEARTBEAT_INTERVAL || '20', 10),
|
||||
jwtExpiresIn: parseInt(process.env.RCS_JWT_EXPIRES_IN || '3600', 10),
|
||||
disconnectTimeout: parseInt(process.env.RCS_DISCONNECT_TIMEOUT || '300', 10),
|
||||
webCorsOrigins: (process.env.RCS_WEB_CORS_ORIGINS || '')
|
||||
.split(',')
|
||||
.map(origin => origin.trim())
|
||||
.filter(Boolean),
|
||||
/** Bun WebSocket idle timeout (seconds). Bun sends protocol-level pings after
|
||||
* this many seconds of no received data. Must be shorter than any reverse
|
||||
* proxy's idle timeout (nginx default 60s, Cloudflare 100s). Default 30s. */
|
||||
wsIdleTimeout: parseInt(process.env.RCS_WS_IDLE_TIMEOUT || "30"),
|
||||
wsIdleTimeout: parseInt(process.env.RCS_WS_IDLE_TIMEOUT || '30', 10),
|
||||
/** Server→client keep_alive data-frame interval (seconds). Keeps reverse
|
||||
* proxies from closing idle connections. Default 20s. */
|
||||
wsKeepaliveInterval: parseInt(process.env.RCS_WS_KEEPALIVE_INTERVAL || "20"),
|
||||
} as const;
|
||||
wsKeepaliveInterval: parseInt(
|
||||
process.env.RCS_WS_KEEPALIVE_INTERVAL || '20',
|
||||
10,
|
||||
),
|
||||
} as const
|
||||
|
||||
export function getBaseUrl(): string {
|
||||
const url = config.baseUrl || `http://localhost:${config.port}`;
|
||||
return url.replace(/\/+$/, "");
|
||||
const url = config.baseUrl || `http://localhost:${config.port}`
|
||||
return url.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
@@ -1,110 +1,119 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
import { serveStatic } from "hono/bun";
|
||||
import { config } from "./config";
|
||||
import { closeAllConnections } from "./transport/ws-handler";
|
||||
import { closeAllAcpConnections } from "./transport/acp-ws-handler";
|
||||
import { closeAllRelayConnections } from "./transport/acp-relay-handler";
|
||||
import { startDisconnectMonitor } from "./services/disconnect-monitor";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import acpRoutes from "./routes/acp";
|
||||
import { webCorsOptions } from "./auth/cors";
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import { logger } from 'hono/logger'
|
||||
import { serveStatic } from 'hono/bun'
|
||||
import { config } from './config'
|
||||
import { closeAllConnections } from './transport/ws-handler'
|
||||
import { closeAllAcpConnections } from './transport/acp-ws-handler'
|
||||
import { closeAllRelayConnections } from './transport/acp-relay-handler'
|
||||
import { startDisconnectMonitor } from './services/disconnect-monitor'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import acpRoutes from './routes/acp'
|
||||
import { webCorsOptions } from './auth/cors'
|
||||
|
||||
// Routes
|
||||
import v1Environments from "./routes/v1/environments";
|
||||
import v1EnvironmentsWork from "./routes/v1/environments.work";
|
||||
import v1Sessions from "./routes/v1/sessions";
|
||||
import v1SessionIngress from "./routes/v1/session-ingress";
|
||||
import { websocket } from "./transport/ws-shared";
|
||||
import v2CodeSessions from "./routes/v2/code-sessions";
|
||||
import v2Worker from "./routes/v2/worker";
|
||||
import v2WorkerEventsStream from "./routes/v2/worker-events-stream";
|
||||
import v2WorkerEvents from "./routes/v2/worker-events";
|
||||
import webAuth from "./routes/web/auth";
|
||||
import webSessions from "./routes/web/sessions";
|
||||
import webControl from "./routes/web/control";
|
||||
import webEnvironments from "./routes/web/environments";
|
||||
import v1Environments from './routes/v1/environments'
|
||||
import v1EnvironmentsWork from './routes/v1/environments.work'
|
||||
import v1Sessions from './routes/v1/sessions'
|
||||
import v1SessionIngress from './routes/v1/session-ingress'
|
||||
import { websocket } from './transport/ws-shared'
|
||||
import v2CodeSessions from './routes/v2/code-sessions'
|
||||
import v2Worker from './routes/v2/worker'
|
||||
import v2WorkerEventsStream from './routes/v2/worker-events-stream'
|
||||
import v2WorkerEvents from './routes/v2/worker-events'
|
||||
import webAuth from './routes/web/auth'
|
||||
import webSessions from './routes/web/sessions'
|
||||
import webControl from './routes/web/control'
|
||||
import webEnvironments from './routes/web/environments'
|
||||
|
||||
console.log("[RCS] In-memory store ready (no SQLite)");
|
||||
console.log('[RCS] In-memory store ready (no SQLite)')
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
// Middleware
|
||||
app.use("*", logger());
|
||||
app.use("*", async (c, next) => {
|
||||
app.use('*', logger())
|
||||
app.use('*', async (c, next) => {
|
||||
// Normalize double slashes in path (e.g. //v1/environments/bridge → /v1/environments/bridge)
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (path.includes("//")) {
|
||||
const normalized = path.replace(/\/+/g, "/");
|
||||
const url = new URL(c.req.url);
|
||||
url.pathname = normalized;
|
||||
return app.fetch(new Request(url.toString(), c.req.raw));
|
||||
const path = new URL(c.req.url).pathname
|
||||
if (path.includes('//')) {
|
||||
const normalized = path.replace(/\/+/g, '/')
|
||||
const url = new URL(c.req.url)
|
||||
url.pathname = normalized
|
||||
return app.fetch(new Request(url.toString(), c.req.raw))
|
||||
}
|
||||
await next();
|
||||
});
|
||||
app.use("/web/*", cors(webCorsOptions));
|
||||
await next()
|
||||
})
|
||||
app.use('/web/*', cors(webCorsOptions))
|
||||
|
||||
// Health check
|
||||
app.get("/health", (c) => c.json({ status: "ok", version: config.version }));
|
||||
app.get('/health', c => c.json({ status: 'ok', version: config.version }))
|
||||
|
||||
// Static files — serve built web UI under /code path
|
||||
// Uses web/dist/ if it exists (production), otherwise falls back to web/ (dev/fallback)
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = resolve(__dirname, "../web/dist");
|
||||
const webDir = existsSync(resolve(distDir, "index.html")) ? distDir : resolve(__dirname, "../web");
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const distDir = resolve(__dirname, '../web/dist')
|
||||
const webDir = existsSync(resolve(distDir, 'index.html'))
|
||||
? distDir
|
||||
: resolve(__dirname, '../web')
|
||||
|
||||
const stripCodePrefix = (p: string) => p.replace(/^\/code/, "");
|
||||
const stripCodePrefix = (p: string) => p.replace(/^\/code/, '')
|
||||
|
||||
// Serve all static files under /code/* from web/ directory
|
||||
app.use("/code/*", serveStatic({ root: webDir, rewriteRequestPath: stripCodePrefix }));
|
||||
app.use(
|
||||
'/code/*',
|
||||
serveStatic({ root: webDir, rewriteRequestPath: stripCodePrefix }),
|
||||
)
|
||||
// /code, /code/, and /code/:sessionId — SPA fallback
|
||||
app.get("/code", serveStatic({ root: webDir, path: "index.html" }));
|
||||
app.get("/code/", serveStatic({ root: webDir, path: "index.html" }));
|
||||
app.get("/code/:sessionId", serveStatic({ root: webDir, path: "index.html" }));
|
||||
app.get('/code', serveStatic({ root: webDir, path: 'index.html' }))
|
||||
app.get('/code/', serveStatic({ root: webDir, path: 'index.html' }))
|
||||
app.get('/code/:sessionId', serveStatic({ root: webDir, path: 'index.html' }))
|
||||
|
||||
// v1 Environment routes
|
||||
app.route("/v1/environments", v1Environments);
|
||||
app.route("/v1/environments", v1EnvironmentsWork);
|
||||
app.route('/v1/environments', v1Environments)
|
||||
app.route('/v1/environments', v1EnvironmentsWork)
|
||||
|
||||
// v1 Session routes
|
||||
app.route("/v1/sessions", v1Sessions);
|
||||
app.route('/v1/sessions', v1Sessions)
|
||||
|
||||
// Session Ingress (WebSocket) — mounted at both /v1 and /v2 so the bridge
|
||||
// client's buildSdkUrl works with or without an Envoy proxy rewriting /v1→/v2.
|
||||
app.route("/v1/session_ingress", v1SessionIngress);
|
||||
app.route("/v2/session_ingress", v1SessionIngress);
|
||||
app.route('/v1/session_ingress', v1SessionIngress)
|
||||
app.route('/v2/session_ingress', v1SessionIngress)
|
||||
|
||||
// v2 Code Sessions routes
|
||||
app.route("/v1/code/sessions", v2CodeSessions);
|
||||
app.route("/v1/code/sessions", v2Worker);
|
||||
app.route("/v1/code/sessions", v2WorkerEventsStream);
|
||||
app.route("/v1/code/sessions", v2WorkerEvents);
|
||||
app.route('/v1/code/sessions', v2CodeSessions)
|
||||
app.route('/v1/code/sessions', v2Worker)
|
||||
app.route('/v1/code/sessions', v2WorkerEventsStream)
|
||||
app.route('/v1/code/sessions', v2WorkerEvents)
|
||||
|
||||
// Web control panel routes
|
||||
app.route("/web", webAuth);
|
||||
app.route("/web", webSessions);
|
||||
app.route("/web", webControl);
|
||||
app.route("/web", webEnvironments);
|
||||
app.route('/web', webAuth)
|
||||
app.route('/web', webSessions)
|
||||
app.route('/web', webControl)
|
||||
app.route('/web', webEnvironments)
|
||||
|
||||
// ACP protocol routes
|
||||
console.log("[RCS] ACP support enabled");
|
||||
app.route("/acp", acpRoutes);
|
||||
console.log('[RCS] ACP support enabled')
|
||||
app.route('/acp', acpRoutes)
|
||||
|
||||
const port = config.port;
|
||||
const host = config.host;
|
||||
const port = config.port
|
||||
const host = config.host
|
||||
|
||||
console.log(`[RCS] Remote Control Server starting on ${host}:${port}`);
|
||||
console.log("[RCS] API key configuration loaded");
|
||||
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`);
|
||||
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`);
|
||||
console.log(`[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`);
|
||||
console.log(`[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`);
|
||||
console.log(`[RCS] Remote Control Server starting on ${host}:${port}`)
|
||||
console.log('[RCS] API key configuration loaded')
|
||||
console.log(`[RCS] Base URL: ${config.baseUrl || `http://localhost:${port}`}`)
|
||||
console.log(`[RCS] Disconnect timeout: ${config.disconnectTimeout}s`)
|
||||
console.log(
|
||||
`[RCS] WebSocket idle timeout: ${config.wsIdleTimeout}s (protocol-level pings)`,
|
||||
)
|
||||
console.log(
|
||||
`[RCS] WebSocket keepalive interval: ${config.wsKeepaliveInterval}s (data frames)`,
|
||||
)
|
||||
|
||||
// Start disconnect monitor
|
||||
startDisconnectMonitor();
|
||||
startDisconnectMonitor()
|
||||
|
||||
export default {
|
||||
port,
|
||||
@@ -115,16 +124,16 @@ export default {
|
||||
idleTimeout: config.wsIdleTimeout, // Bun sends protocol pings after this many seconds of silence
|
||||
},
|
||||
idleTimeout: config.wsIdleTimeout, // HTTP server idle timeout (seconds)
|
||||
};
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
async function gracefulShutdown(signal: string) {
|
||||
console.log(`\n[RCS] Received ${signal}, shutting down...`);
|
||||
closeAllConnections();
|
||||
closeAllAcpConnections();
|
||||
closeAllRelayConnections();
|
||||
process.exit(0);
|
||||
console.log(`\n[RCS] Received ${signal}, shutting down...`)
|
||||
closeAllConnections()
|
||||
closeAllAcpConnections()
|
||||
closeAllRelayConnections()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on('SIGINT', () => gracefulShutdown('SIGINT'))
|
||||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'))
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/** Thin logging wrapper — silent in test environment, uses console in production. */
|
||||
const isTest = process.env.NODE_ENV === "test" || (typeof Bun !== "undefined" && !!Bun.env.BUN_TEST);
|
||||
const isTest =
|
||||
process.env.NODE_ENV === 'test' ||
|
||||
(typeof Bun !== 'undefined' && !!Bun.env.BUN_TEST)
|
||||
|
||||
export function log(...args: unknown[]): void {
|
||||
if (!isTest) console.log(...args);
|
||||
if (!isTest) console.log(...args)
|
||||
}
|
||||
|
||||
export function error(...args: unknown[]): void {
|
||||
if (!isTest) console.error(...args);
|
||||
if (!isTest) console.error(...args)
|
||||
}
|
||||
|
||||
@@ -1,224 +1,232 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Context } from "hono";
|
||||
import type { WSContext, WSMessageReceive } from "hono/ws";
|
||||
import { upgradeWebSocket } from "../../transport/ws-shared";
|
||||
import { Hono } from 'hono'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from 'hono'
|
||||
import type { WSContext, WSMessageReceive } from 'hono/ws'
|
||||
import { upgradeWebSocket } from '../../transport/ws-shared'
|
||||
import {
|
||||
decodeWsPayload,
|
||||
handleSizedWsPayload,
|
||||
} from "../../transport/ws-payload";
|
||||
} from '../../transport/ws-payload'
|
||||
import {
|
||||
extractBearerToken,
|
||||
extractWebSocketAuthToken,
|
||||
} from "../../auth/middleware";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
} from '../../auth/middleware'
|
||||
import { validateApiKey } from '../../auth/api-key'
|
||||
import {
|
||||
handleAcpWsOpen,
|
||||
handleAcpWsMessage,
|
||||
handleAcpWsClose,
|
||||
} from "../../transport/acp-ws-handler";
|
||||
} from '../../transport/acp-ws-handler'
|
||||
import {
|
||||
handleRelayOpen,
|
||||
handleRelayMessage,
|
||||
handleRelayClose,
|
||||
} from "../../transport/acp-relay-handler";
|
||||
} from '../../transport/acp-relay-handler'
|
||||
import {
|
||||
storeListAcpAgents,
|
||||
storeListAcpAgentsByChannelGroup,
|
||||
storeGetEnvironment,
|
||||
} from "../../store";
|
||||
import { createAcpSSEStream } from "../../transport/acp-sse-writer";
|
||||
import { log, error as logError } from "../../logger";
|
||||
} from '../../store'
|
||||
import { createAcpSSEStream } from '../../transport/acp-sse-writer'
|
||||
import { log, error as logError } from '../../logger'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
type WsMessageEvent = {
|
||||
data: WSMessageReceive;
|
||||
};
|
||||
data: WSMessageReceive
|
||||
}
|
||||
|
||||
type WsCloseEvent = {
|
||||
code?: number;
|
||||
reason?: string;
|
||||
};
|
||||
code?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** Response shape for an ACP agent */
|
||||
function toAcpAgentResponse(env: ReturnType<typeof storeGetEnvironment> & {}) {
|
||||
if (!env) return null;
|
||||
if (!env) return null
|
||||
return {
|
||||
id: env.id,
|
||||
agent_name: env.machineName,
|
||||
channel_group_id: env.bridgeId,
|
||||
status: env.status === "active" ? "online" : "offline",
|
||||
status: env.status === 'active' ? 'online' : 'offline',
|
||||
max_sessions: env.maxSessions,
|
||||
last_seen_at: env.lastPollAt ? env.lastPollAt.getTime() / 1000 : null,
|
||||
created_at: env.createdAt.getTime() / 1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function hasAcpReadAuth(c: Context): boolean {
|
||||
const token = extractBearerToken(c);
|
||||
return !!token && validateApiKey(token);
|
||||
const token = extractBearerToken(c)
|
||||
return !!token && validateApiKey(token)
|
||||
}
|
||||
|
||||
export function hasAcpRelayAuth(c: Context): boolean {
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
return !!token && validateApiKey(token);
|
||||
const token = extractWebSocketAuthToken(c)
|
||||
return !!token && validateApiKey(token)
|
||||
}
|
||||
|
||||
function acpReadUnauthorized(c: Context) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Missing auth" } }, 401);
|
||||
return c.json(
|
||||
{ error: { type: 'unauthorized', message: 'Missing auth' } },
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
/** GET /acp/agents — List all registered ACP agents (API key auth) */
|
||||
app.get("/agents", async (c) => {
|
||||
app.get('/agents', async c => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
return acpReadUnauthorized(c)
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
return c.json(agents.map((a) => toAcpAgentResponse(a)).filter(Boolean));
|
||||
});
|
||||
const agents = storeListAcpAgents()
|
||||
return c.json(agents.map(a => toAcpAgentResponse(a)).filter(Boolean))
|
||||
})
|
||||
|
||||
/** GET /acp/channel-groups — List all channel groups with member agents (API key auth) */
|
||||
app.get("/channel-groups", async (c) => {
|
||||
app.get('/channel-groups', async c => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
return acpReadUnauthorized(c)
|
||||
}
|
||||
const agents = storeListAcpAgents();
|
||||
const groupMap = new Map<string, typeof agents>();
|
||||
const agents = storeListAcpAgents()
|
||||
const groupMap = new Map<string, typeof agents>()
|
||||
for (const agent of agents) {
|
||||
const groupId = agent.bridgeId || "default";
|
||||
const groupId = agent.bridgeId || 'default'
|
||||
if (!groupMap.has(groupId)) {
|
||||
groupMap.set(groupId, []);
|
||||
groupMap.set(groupId, [])
|
||||
}
|
||||
groupMap.get(groupId)!.push(agent);
|
||||
groupMap.get(groupId)!.push(agent)
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([id, members]) => ({
|
||||
channel_group_id: id,
|
||||
member_count: members.length,
|
||||
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
|
||||
}));
|
||||
return c.json(groups);
|
||||
});
|
||||
members: members.map(m => toAcpAgentResponse(m)).filter(Boolean),
|
||||
}))
|
||||
return c.json(groups)
|
||||
})
|
||||
|
||||
/** GET /acp/channel-groups/:id — Specific channel group detail (API key auth) */
|
||||
app.get("/channel-groups/:id", async (c) => {
|
||||
app.get('/channel-groups/:id', async c => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
return acpReadUnauthorized(c)
|
||||
}
|
||||
|
||||
const groupId = c.req.param("id")!;
|
||||
const members = storeListAcpAgentsByChannelGroup(groupId);
|
||||
const groupId = c.req.param('id')!
|
||||
const members = storeListAcpAgentsByChannelGroup(groupId)
|
||||
if (members.length === 0) {
|
||||
return c.json({ error: { type: "not_found", message: "Channel group not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Channel group not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
return c.json({
|
||||
channel_group_id: groupId,
|
||||
member_count: members.length,
|
||||
members: members.map((m) => toAcpAgentResponse(m)).filter(Boolean),
|
||||
});
|
||||
});
|
||||
members: members.map(m => toAcpAgentResponse(m)).filter(Boolean),
|
||||
})
|
||||
})
|
||||
|
||||
/** SSE /acp/channel-groups/:id/events — Event stream for external consumers (API key auth) */
|
||||
app.get("/channel-groups/:id/events", async (c) => {
|
||||
app.get('/channel-groups/:id/events', async c => {
|
||||
if (!hasAcpReadAuth(c)) {
|
||||
return acpReadUnauthorized(c);
|
||||
return acpReadUnauthorized(c)
|
||||
}
|
||||
|
||||
const groupId = c.req.param("id")!;
|
||||
const groupId = c.req.param('id')!
|
||||
|
||||
// Support Last-Event-ID / from_sequence_num for reconnection
|
||||
const lastEventId = c.req.header("Last-Event-ID");
|
||||
const fromSeq = c.req.query("from_sequence_num");
|
||||
const fromSeqNum = fromSeq ? parseInt(fromSeq, 10) : lastEventId ? parseInt(lastEventId, 10) : 0;
|
||||
const lastEventId = c.req.header('Last-Event-ID')
|
||||
const fromSeq = c.req.query('from_sequence_num')
|
||||
const fromSeqNum = fromSeq
|
||||
? parseInt(fromSeq, 10)
|
||||
: lastEventId
|
||||
? parseInt(lastEventId, 10)
|
||||
: 0
|
||||
|
||||
return createAcpSSEStream(c, groupId, fromSeqNum);
|
||||
});
|
||||
return createAcpSSEStream(c, groupId, fromSeqNum)
|
||||
})
|
||||
|
||||
/** WS /acp/ws — WebSocket endpoint for acp-link connections */
|
||||
app.get(
|
||||
"/ws",
|
||||
upgradeWebSocket(async (c) => {
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
'/ws',
|
||||
upgradeWebSocket(async c => {
|
||||
const token = extractWebSocketAuthToken(c)
|
||||
|
||||
if (!token || !validateApiKey(token)) {
|
||||
log("[ACP-WS] Upgrade rejected: unauthorized");
|
||||
log('[ACP-WS] Upgrade rejected: unauthorized')
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4003, "unauthorized");
|
||||
ws.close(4003, 'unauthorized')
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique wsId for this connection
|
||||
const wsId = `acp_ws_${randomUUID().replace(/-/g, "")}`;
|
||||
const wsId = `acp_ws_${randomUUID().replace(/-/g, '')}`
|
||||
|
||||
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`);
|
||||
log(`[ACP-WS] Upgrade accepted: wsId=${wsId}`)
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
handleAcpWsOpen(ws, wsId);
|
||||
handleAcpWsOpen(ws, wsId)
|
||||
},
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleAcpWsPayload(
|
||||
ws,
|
||||
"[ACP-WS]",
|
||||
`wsId=${wsId}`,
|
||||
evt.data,
|
||||
data => handleAcpWsMessage(ws, wsId, data),
|
||||
);
|
||||
handleAcpWsPayload(ws, '[ACP-WS]', `wsId=${wsId}`, evt.data, data =>
|
||||
handleAcpWsMessage(ws, wsId, data),
|
||||
)
|
||||
},
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleAcpWsClose(ws, wsId, evt.code, evt.reason);
|
||||
handleAcpWsClose(ws, wsId, evt.code, evt.reason)
|
||||
},
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt);
|
||||
handleAcpWsClose(ws, wsId, 1006, "websocket error");
|
||||
logError(`[ACP-WS] Error on wsId=${wsId}:`, evt)
|
||||
handleAcpWsClose(ws, wsId, 1006, 'websocket error')
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
)
|
||||
|
||||
/** WS /acp/relay/:agentId — WebSocket relay for frontend to interact with an agent */
|
||||
app.get(
|
||||
"/relay/:agentId",
|
||||
upgradeWebSocket(async (c) => {
|
||||
'/relay/:agentId',
|
||||
upgradeWebSocket(async c => {
|
||||
if (!hasAcpRelayAuth(c)) {
|
||||
log("[ACP-Relay] Upgrade rejected: unauthorized");
|
||||
log('[ACP-Relay] Upgrade rejected: unauthorized')
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4003, "unauthorized");
|
||||
ws.close(4003, 'unauthorized')
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const agentId = c.req.param("agentId")!;
|
||||
const relayWsId = `relay_${randomUUID().replace(/-/g, "")}`;
|
||||
const agentId = c.req.param('agentId')!
|
||||
const relayWsId = `relay_${randomUUID().replace(/-/g, '')}`
|
||||
|
||||
log(`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`);
|
||||
log(
|
||||
`[ACP-Relay] Upgrade accepted: relayWsId=${relayWsId} agentId=${agentId}`,
|
||||
)
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
handleRelayOpen(ws, relayWsId, agentId);
|
||||
handleRelayOpen(ws, relayWsId, agentId)
|
||||
},
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleAcpWsPayload(
|
||||
ws,
|
||||
"[ACP-Relay]",
|
||||
'[ACP-Relay]',
|
||||
`relayWsId=${relayWsId}`,
|
||||
evt.data,
|
||||
data => handleRelayMessage(ws, relayWsId, data),
|
||||
);
|
||||
)
|
||||
},
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleRelayClose(ws, relayWsId, evt.code, evt.reason);
|
||||
handleRelayClose(ws, relayWsId, evt.code, evt.reason)
|
||||
},
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt);
|
||||
handleRelayClose(ws, relayWsId, 1006, "websocket error");
|
||||
logError(`[ACP-Relay] Error on relayWsId=${relayWsId}:`, evt)
|
||||
handleRelayClose(ws, relayWsId, 1006, 'websocket error')
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
)
|
||||
|
||||
export const decodeAcpWsMessageData = decodeWsPayload;
|
||||
export const decodeAcpWsMessageData = decodeWsPayload
|
||||
|
||||
export function handleAcpWsPayload(
|
||||
ws: WSContext,
|
||||
@@ -227,7 +235,7 @@ export function handleAcpWsPayload(
|
||||
payload: unknown,
|
||||
handleMessage: (data: string) => void,
|
||||
): boolean {
|
||||
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage);
|
||||
return handleSizedWsPayload(ws, logPrefix, label, payload, handleMessage)
|
||||
}
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,39 +1,45 @@
|
||||
import { Hono } from "hono";
|
||||
import { registerEnvironment, deregisterEnvironment, reconnectEnvironment } from "../../services/environment";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { Hono } from 'hono'
|
||||
import {
|
||||
registerEnvironment,
|
||||
deregisterEnvironment,
|
||||
reconnectEnvironment,
|
||||
} from '../../services/environment'
|
||||
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
|
||||
import { storeBindSession } from '../../store'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** POST /v1/environments/bridge — Register an environment */
|
||||
app.post("/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const username = c.get("username");
|
||||
const result = registerEnvironment({ ...body, username });
|
||||
app.post('/bridge', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const body = await c.req.json()
|
||||
const username = c.get('username')
|
||||
const result = registerEnvironment({ ...body, username })
|
||||
// Bind ACP session to the group ID so the web UI can find it by group
|
||||
if (result.session_id) {
|
||||
const groupId = body.bridge_id as string | undefined;
|
||||
const groupId = body.bridge_id as string | undefined
|
||||
if (groupId) {
|
||||
storeBindSession(result.session_id, groupId);
|
||||
storeBindSession(result.session_id, groupId)
|
||||
}
|
||||
}
|
||||
return c.json(result, 200);
|
||||
});
|
||||
return c.json(result, 200)
|
||||
})
|
||||
|
||||
/** DELETE /v1/environments/bridge/:id — Deregister */
|
||||
app.delete("/bridge/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id")!;
|
||||
deregisterEnvironment(envId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
app.delete('/bridge/:id', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const envId = c.req.param('id')!
|
||||
deregisterEnvironment(envId)
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
/** POST /v1/environments/:id/bridge/reconnect — Reconnect */
|
||||
app.post("/:id/bridge/reconnect", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id")!;
|
||||
reconnectEnvironment(envId);
|
||||
const { reconnectWorkForEnvironment } = await import("../../services/work-dispatch");
|
||||
await reconnectWorkForEnvironment(envId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
app.post('/:id/bridge/reconnect', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const envId = c.req.param('id')!
|
||||
reconnectEnvironment(envId)
|
||||
const { reconnectWorkForEnvironment } = await import(
|
||||
'../../services/work-dispatch'
|
||||
)
|
||||
await reconnectWorkForEnvironment(envId)
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,41 +1,51 @@
|
||||
import { Hono } from "hono";
|
||||
import { pollWork, ackWork, stopWork, heartbeatWork } from "../../services/work-dispatch";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { updatePollTime } from "../../services/environment";
|
||||
import { Hono } from 'hono'
|
||||
import {
|
||||
pollWork,
|
||||
ackWork,
|
||||
stopWork,
|
||||
heartbeatWork,
|
||||
} from '../../services/work-dispatch'
|
||||
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
|
||||
import { updatePollTime } from '../../services/environment'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** GET /v1/environments/:id/work/poll — Long-poll for work */
|
||||
app.get("/:id/work/poll", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const envId = c.req.param("id")!;
|
||||
updatePollTime(envId);
|
||||
const result = await pollWork(envId);
|
||||
app.get('/:id/work/poll', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const envId = c.req.param('id')!
|
||||
updatePollTime(envId)
|
||||
const result = await pollWork(envId)
|
||||
if (!result) {
|
||||
// Return 204 No Content so the client's axios parses it as null
|
||||
return c.body(null, 204);
|
||||
return c.body(null, 204)
|
||||
}
|
||||
return c.json(result, 200);
|
||||
});
|
||||
return c.json(result, 200)
|
||||
})
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/ack — Acknowledge work */
|
||||
app.post("/:id/work/:workId/ack", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId")!;
|
||||
ackWork(workId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
app.post('/:id/work/:workId/ack', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const workId = c.req.param('workId')!
|
||||
ackWork(workId)
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/stop — Stop work */
|
||||
app.post("/:id/work/:workId/stop", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId")!;
|
||||
stopWork(workId);
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
app.post('/:id/work/:workId/stop', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const workId = c.req.param('workId')!
|
||||
stopWork(workId)
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
/** POST /v1/environments/:id/work/:workId/heartbeat — Heartbeat */
|
||||
app.post("/:id/work/:workId/heartbeat", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const workId = c.req.param("workId")!;
|
||||
const result = heartbeatWork(workId);
|
||||
return c.json(result, 200);
|
||||
});
|
||||
app.post(
|
||||
'/:id/work/:workId/heartbeat',
|
||||
acceptCliHeaders,
|
||||
apiKeyAuth,
|
||||
async c => {
|
||||
const workId = c.req.param('workId')!
|
||||
const result = heartbeatWork(workId)
|
||||
return c.json(result, 200)
|
||||
},
|
||||
)
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,131 +1,143 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import type { Context } from "hono";
|
||||
import type { WSContext, WSMessageReceive } from "hono/ws";
|
||||
import { upgradeWebSocket, websocket } from "../../transport/ws-shared";
|
||||
import { log, error as logError } from '../../logger'
|
||||
import { Hono } from 'hono'
|
||||
import type { Context } from 'hono'
|
||||
import type { WSContext, WSMessageReceive } from 'hono/ws'
|
||||
import { upgradeWebSocket, websocket } from '../../transport/ws-shared'
|
||||
import {
|
||||
decodeWsPayload,
|
||||
handleSizedWsPayload,
|
||||
} from "../../transport/ws-payload";
|
||||
import { validateApiKey } from "../../auth/api-key";
|
||||
import { verifyWorkerJwt } from "../../auth/jwt";
|
||||
import { extractWebSocketAuthToken } from "../../auth/middleware";
|
||||
} from '../../transport/ws-payload'
|
||||
import { validateApiKey } from '../../auth/api-key'
|
||||
import { verifyWorkerJwt } from '../../auth/jwt'
|
||||
import { extractWebSocketAuthToken } from '../../auth/middleware'
|
||||
import {
|
||||
handleWebSocketOpen,
|
||||
handleWebSocketMessage,
|
||||
handleWebSocketClose,
|
||||
ingestBridgeMessage,
|
||||
} from "../../transport/ws-handler";
|
||||
import { getSession, resolveExistingSessionId } from "../../services/session";
|
||||
} from '../../transport/ws-handler'
|
||||
import { getSession, resolveExistingSessionId } from '../../services/session'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
type WsMessageEvent = {
|
||||
data: WSMessageReceive;
|
||||
};
|
||||
data: WSMessageReceive
|
||||
}
|
||||
|
||||
type WsCloseEvent = {
|
||||
code?: number;
|
||||
reason?: string;
|
||||
};
|
||||
code?: number
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** Authenticate via API key or worker JWT without accepting URL query secrets. */
|
||||
function authenticateRequest(c: Context, label: string, expectedSessionId?: string): boolean {
|
||||
const token = extractWebSocketAuthToken(c);
|
||||
function authenticateRequest(
|
||||
c: Context,
|
||||
label: string,
|
||||
expectedSessionId?: string,
|
||||
): boolean {
|
||||
const token = extractWebSocketAuthToken(c)
|
||||
|
||||
// Try API key first
|
||||
if (validateApiKey(token)) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
// Try JWT verification — validate session_id matches if provided
|
||||
if (token) {
|
||||
const payload = verifyWorkerJwt(token);
|
||||
const payload = verifyWorkerJwt(token)
|
||||
if (payload) {
|
||||
if (expectedSessionId && payload.session_id !== expectedSessionId) {
|
||||
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`);
|
||||
return false;
|
||||
log(`[Auth] ${label}: FAILED — JWT session_id mismatch`)
|
||||
return false
|
||||
}
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
log(`[Auth] ${label}: FAILED — no valid API key or JWT`);
|
||||
return false;
|
||||
log(`[Auth] ${label}: FAILED — no valid API key or JWT`)
|
||||
return false
|
||||
}
|
||||
|
||||
/** POST /v2/session_ingress/session/:sessionId/events — HTTP POST (HybridTransport writes) */
|
||||
app.post("/session/:sessionId/events", async (c) => {
|
||||
const requestedSessionId = c.req.param("sessionId")!;
|
||||
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
|
||||
app.post('/session/:sessionId/events', async c => {
|
||||
const requestedSessionId = c.req.param('sessionId')!
|
||||
const sessionId =
|
||||
resolveExistingSessionId(requestedSessionId) ?? requestedSessionId
|
||||
|
||||
if (!authenticateRequest(c, `POST session/${sessionId}`, sessionId)) {
|
||||
return c.json({ error: { type: "unauthorized", message: "Invalid auth" } }, 401);
|
||||
return c.json(
|
||||
{ error: { type: 'unauthorized', message: 'Invalid auth' } },
|
||||
401,
|
||||
)
|
||||
}
|
||||
|
||||
const session = getSession(sessionId);
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const events = Array.isArray(body.events) ? body.events : [body];
|
||||
const body = await c.req.json()
|
||||
const events = Array.isArray(body.events) ? body.events : [body]
|
||||
|
||||
let count = 0;
|
||||
let count = 0
|
||||
for (const msg of events) {
|
||||
if (!msg || typeof msg !== "object") continue;
|
||||
ingestBridgeMessage(sessionId, msg as Record<string, unknown>);
|
||||
count++;
|
||||
if (!msg || typeof msg !== 'object') continue
|
||||
ingestBridgeMessage(sessionId, msg as Record<string, unknown>)
|
||||
count++
|
||||
}
|
||||
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
/** WS /v2/session_ingress/ws/:sessionId — WebSocket transport */
|
||||
app.get(
|
||||
"/ws/:sessionId",
|
||||
upgradeWebSocket(async (c) => {
|
||||
const requestedSessionId = c.req.param("sessionId")!;
|
||||
const sessionId = resolveExistingSessionId(requestedSessionId) ?? requestedSessionId;
|
||||
'/ws/:sessionId',
|
||||
upgradeWebSocket(async c => {
|
||||
const requestedSessionId = c.req.param('sessionId')!
|
||||
const sessionId =
|
||||
resolveExistingSessionId(requestedSessionId) ?? requestedSessionId
|
||||
|
||||
if (!authenticateRequest(c, `WS ${sessionId}`, sessionId)) {
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4003, "unauthorized");
|
||||
ws.close(4003, 'unauthorized')
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const session = getSession(sessionId);
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`);
|
||||
log(`[WS] Upgrade rejected: session ${sessionId} not found`)
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
ws.close(4001, "session not found");
|
||||
ws.close(4001, 'session not found')
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
log(`[WS] Upgrade accepted: session=${sessionId}`);
|
||||
log(`[WS] Upgrade accepted: session=${sessionId}`)
|
||||
return {
|
||||
onOpen(_evt: Event, ws: WSContext) {
|
||||
handleWebSocketOpen(ws, sessionId);
|
||||
handleWebSocketOpen(ws, sessionId)
|
||||
},
|
||||
onMessage(evt: WsMessageEvent, ws: WSContext) {
|
||||
handleSessionIngressWsPayload(ws, sessionId, evt.data);
|
||||
handleSessionIngressWsPayload(ws, sessionId, evt.data)
|
||||
},
|
||||
onClose(evt: WsCloseEvent, ws: WSContext) {
|
||||
handleWebSocketClose(ws, sessionId, evt.code, evt.reason);
|
||||
handleWebSocketClose(ws, sessionId, evt.code, evt.reason)
|
||||
},
|
||||
onError(evt: Event, ws: WSContext) {
|
||||
logError(`[WS] Error on session=${sessionId}:`, evt);
|
||||
handleWebSocketClose(ws, sessionId, 1006, "websocket error");
|
||||
logError(`[WS] Error on session=${sessionId}:`, evt)
|
||||
handleWebSocketClose(ws, sessionId, 1006, 'websocket error')
|
||||
},
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
)
|
||||
|
||||
export const decodeSessionIngressWsMessage = decodeWsPayload;
|
||||
export const decodeSessionIngressWsMessage = decodeWsPayload
|
||||
|
||||
export function handleSessionIngressWsPayload(
|
||||
ws: WSContext,
|
||||
@@ -134,12 +146,12 @@ export function handleSessionIngressWsPayload(
|
||||
): boolean {
|
||||
return handleSizedWsPayload(
|
||||
ws,
|
||||
"[WS]",
|
||||
'[WS]',
|
||||
`session=${sessionId}`,
|
||||
payload,
|
||||
data => handleWebSocketMessage(ws, sessionId, data),
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { websocket };
|
||||
export default app;
|
||||
export { websocket }
|
||||
export default app
|
||||
|
||||
@@ -1,104 +1,129 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { log, error as logError } from '../../logger'
|
||||
import { Hono } from 'hono'
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
updateSessionTitle,
|
||||
archiveSession,
|
||||
resolveExistingSessionId,
|
||||
} from "../../services/session";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { publishSessionEvent } from "../../services/transport";
|
||||
} from '../../services/session'
|
||||
import { createWorkItem } from '../../services/work-dispatch'
|
||||
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
|
||||
import { publishSessionEvent } from '../../services/transport'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** POST /v1/sessions — Create session */
|
||||
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const username = c.get("username");
|
||||
const session = createSession({ ...body, username });
|
||||
app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const body = await c.req.json()
|
||||
const username = c.get('username')
|
||||
const session = createSession({ ...body, username })
|
||||
|
||||
// Create work item if environment is specified
|
||||
if (body.environment_id) {
|
||||
try {
|
||||
await createWorkItem(body.environment_id, session.id);
|
||||
await createWorkItem(body.environment_id, session.id)
|
||||
} catch (err) {
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish initial events if provided
|
||||
if (body.events && Array.isArray(body.events)) {
|
||||
for (const evt of body.events) {
|
||||
publishSessionEvent(session.id, evt.type || "init", evt, "outbound");
|
||||
publishSessionEvent(session.id, evt.type || 'init', evt, 'outbound')
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(session, 200);
|
||||
});
|
||||
return c.json(session, 200)
|
||||
})
|
||||
|
||||
/** GET /v1/sessions/:id — Get session */
|
||||
app.get("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
app.get('/:id', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const sessionId =
|
||||
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
return c.json(session, 200);
|
||||
});
|
||||
return c.json(session, 200)
|
||||
})
|
||||
|
||||
/** PATCH /v1/sessions/:id — Update session title */
|
||||
app.patch("/:id", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
|
||||
const existing = getSession(sessionId);
|
||||
app.patch('/:id', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const sessionId =
|
||||
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
|
||||
const existing = getSession(sessionId)
|
||||
if (!existing) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const body = await c.req.json()
|
||||
if (body.title) {
|
||||
updateSessionTitle(sessionId, body.title);
|
||||
updateSessionTitle(sessionId, body.title)
|
||||
}
|
||||
const session = getSession(sessionId);
|
||||
return c.json(session, 200);
|
||||
});
|
||||
const session = getSession(sessionId)
|
||||
return c.json(session, 200)
|
||||
})
|
||||
|
||||
/** POST /v1/sessions/:id/archive — Archive session */
|
||||
app.post("/:id/archive", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
app.post('/:id/archive', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const sessionId =
|
||||
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
archiveSession(sessionId);
|
||||
archiveSession(sessionId)
|
||||
} catch {
|
||||
return c.json({ status: "ok" }, 409);
|
||||
return c.json({ status: 'ok' }, 409)
|
||||
}
|
||||
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
/** POST /v1/sessions/:id/events — Send event to session */
|
||||
app.post("/:id/events", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = resolveExistingSessionId(c.req.param("id")!) ?? c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
app.post('/:id/events', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const sessionId =
|
||||
resolveExistingSessionId(c.req.param('id')!) ?? c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const body = await c.req.json()
|
||||
|
||||
const events = body.events
|
||||
? Array.isArray(body.events) ? body.events : [body.events]
|
||||
: Array.isArray(body) ? body : [body];
|
||||
const published = [];
|
||||
? Array.isArray(body.events)
|
||||
? body.events
|
||||
: [body.events]
|
||||
: Array.isArray(body)
|
||||
? body
|
||||
: [body]
|
||||
const published = []
|
||||
for (const evt of events) {
|
||||
const result = publishSessionEvent(sessionId, evt.type || "message", evt, "inbound");
|
||||
published.push(result);
|
||||
const result = publishSessionEvent(
|
||||
sessionId,
|
||||
evt.type || 'message',
|
||||
evt,
|
||||
'inbound',
|
||||
)
|
||||
published.push(result)
|
||||
}
|
||||
|
||||
return c.json({ status: "ok", events: published.length }, 200);
|
||||
});
|
||||
return c.json({ status: 'ok', events: published.length }, 200)
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,36 +1,46 @@
|
||||
import { Hono } from "hono";
|
||||
import { createCodeSession, getSession, incrementEpoch } from "../../services/session";
|
||||
import { apiKeyAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { generateWorkerJwt } from "../../auth/jwt";
|
||||
import { getBaseUrl, config } from "../../config";
|
||||
import { Hono } from 'hono'
|
||||
import {
|
||||
createCodeSession,
|
||||
getSession,
|
||||
incrementEpoch,
|
||||
} from '../../services/session'
|
||||
import { apiKeyAuth, acceptCliHeaders } from '../../auth/middleware'
|
||||
import { generateWorkerJwt } from '../../auth/jwt'
|
||||
import { getBaseUrl, config } from '../../config'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** POST /v1/code/sessions — Create code session (wrapped response for TUI compat) */
|
||||
app.post("/", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const body = await c.req.json();
|
||||
const session = createCodeSession(body);
|
||||
return c.json({ session }, 200);
|
||||
});
|
||||
app.post('/', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const body = await c.req.json()
|
||||
const session = createCodeSession(body)
|
||||
return c.json({ session }, 200)
|
||||
})
|
||||
|
||||
/** POST /v1/code/sessions/:id/bridge — Get connection info + worker JWT */
|
||||
app.post("/:id/bridge", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
app.post('/:id/bridge', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
const epoch = incrementEpoch(sessionId);
|
||||
const expiresInSeconds = config.jwtExpiresIn;
|
||||
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds);
|
||||
const epoch = incrementEpoch(sessionId)
|
||||
const expiresInSeconds = config.jwtExpiresIn
|
||||
const workerJwt = generateWorkerJwt(sessionId, expiresInSeconds)
|
||||
|
||||
return c.json({
|
||||
api_base_url: getBaseUrl(),
|
||||
worker_epoch: epoch,
|
||||
worker_jwt: workerJwt,
|
||||
expires_in: expiresInSeconds,
|
||||
}, 200);
|
||||
});
|
||||
return c.json(
|
||||
{
|
||||
api_base_url: getBaseUrl(),
|
||||
worker_epoch: epoch,
|
||||
worker_jwt: workerJwt,
|
||||
expires_in: expiresInSeconds,
|
||||
},
|
||||
200,
|
||||
)
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,24 +1,36 @@
|
||||
import { Hono } from "hono";
|
||||
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { createWorkerEventStream } from "../../transport/sse-writer";
|
||||
import { getSession } from "../../services/session";
|
||||
import { Hono } from 'hono'
|
||||
import { sessionIngressAuth, acceptCliHeaders } from '../../auth/middleware'
|
||||
import { createWorkerEventStream } from '../../transport/sse-writer'
|
||||
import { getSession } from '../../services/session'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** SSE /v1/code/sessions/:id/worker/events/stream — SSE event stream */
|
||||
app.get("/:id/worker/events/stream", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
app.get(
|
||||
'/:id/worker/events/stream',
|
||||
acceptCliHeaders,
|
||||
sessionIngressAuth,
|
||||
async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
// Support Last-Event-ID / from_sequence_num for reconnection
|
||||
const lastEventId = c.req.header("Last-Event-ID");
|
||||
const fromSeq = c.req.query("from_sequence_num");
|
||||
const fromSeqNum = fromSeq ? parseInt(fromSeq) : lastEventId ? parseInt(lastEventId) : 0;
|
||||
// Support Last-Event-ID / from_sequence_num for reconnection
|
||||
const lastEventId = c.req.header('Last-Event-ID')
|
||||
const fromSeq = c.req.query('from_sequence_num')
|
||||
const fromSeqNum = fromSeq
|
||||
? parseInt(fromSeq, 10)
|
||||
: lastEventId
|
||||
? parseInt(lastEventId, 10)
|
||||
: 0
|
||||
|
||||
return createWorkerEventStream(c, sessionId, fromSeqNum);
|
||||
});
|
||||
return createWorkerEventStream(c, sessionId, fromSeqNum)
|
||||
},
|
||||
)
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,99 +1,144 @@
|
||||
import { Hono } from "hono";
|
||||
import { sessionIngressAuth, acceptCliHeaders } from "../../auth/middleware";
|
||||
import { publishSessionEvent } from "../../services/transport";
|
||||
import { getSession, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import { Hono } from 'hono'
|
||||
import { sessionIngressAuth, acceptCliHeaders } from '../../auth/middleware'
|
||||
import { publishSessionEvent } from '../../services/transport'
|
||||
import {
|
||||
getSession,
|
||||
touchSession,
|
||||
updateSessionStatus,
|
||||
} from '../../services/session'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
function extractWorkerEvents(body: unknown): Array<Record<string, unknown>> {
|
||||
if (!body || typeof body !== "object") {
|
||||
return [];
|
||||
if (!body || typeof body !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const payload = body as Record<string, unknown>;
|
||||
const payload = body as Record<string, unknown>
|
||||
const rawEvents = Array.isArray(payload.events)
|
||||
? payload.events
|
||||
: Array.isArray(body)
|
||||
? body
|
||||
: [body];
|
||||
: [body]
|
||||
|
||||
return rawEvents
|
||||
.filter((evt): evt is Record<string, unknown> => !!evt && typeof evt === "object")
|
||||
.map((evt) => {
|
||||
const wrappedPayload = evt.payload;
|
||||
if (wrappedPayload && typeof wrappedPayload === "object" && !Array.isArray(wrappedPayload)) {
|
||||
return wrappedPayload as Record<string, unknown>;
|
||||
.filter(
|
||||
(evt): evt is Record<string, unknown> => !!evt && typeof evt === 'object',
|
||||
)
|
||||
.map(evt => {
|
||||
const wrappedPayload = evt.payload
|
||||
if (
|
||||
wrappedPayload &&
|
||||
typeof wrappedPayload === 'object' &&
|
||||
!Array.isArray(wrappedPayload)
|
||||
) {
|
||||
return wrappedPayload as Record<string, unknown>
|
||||
}
|
||||
return evt;
|
||||
});
|
||||
return evt
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/code/sessions/:id/worker/events — Write events */
|
||||
app.post("/:id/worker/events", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
const body = await c.req.json();
|
||||
app.post(
|
||||
'/:id/worker/events',
|
||||
acceptCliHeaders,
|
||||
sessionIngressAuth,
|
||||
async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
const body = await c.req.json()
|
||||
|
||||
const events = extractWorkerEvents(body);
|
||||
const published = [];
|
||||
for (const evt of events) {
|
||||
const eventType = typeof evt.type === "string" ? evt.type : "message";
|
||||
const result = publishSessionEvent(sessionId, eventType, evt, "inbound");
|
||||
published.push(result);
|
||||
}
|
||||
const events = extractWorkerEvents(body)
|
||||
const published = []
|
||||
for (const evt of events) {
|
||||
const eventType = typeof evt.type === 'string' ? evt.type : 'message'
|
||||
const result = publishSessionEvent(sessionId, eventType, evt, 'inbound')
|
||||
published.push(result)
|
||||
}
|
||||
|
||||
touchSession(sessionId);
|
||||
touchSession(sessionId)
|
||||
|
||||
return c.json({ status: "ok", count: published.length }, 200);
|
||||
});
|
||||
return c.json({ status: 'ok', count: published.length }, 200)
|
||||
},
|
||||
)
|
||||
|
||||
/** PUT /v1/code/sessions/:id/worker/state — Report worker state */
|
||||
app.put("/:id/worker/state", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
app.put('/:id/worker/state', acceptCliHeaders, sessionIngressAuth, async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
const body = await c.req.json();
|
||||
const body = await c.req.json()
|
||||
|
||||
if (body.status) {
|
||||
updateSessionStatus(sessionId, body.status);
|
||||
updateSessionStatus(sessionId, body.status)
|
||||
} else {
|
||||
touchSession(sessionId);
|
||||
touchSession(sessionId)
|
||||
}
|
||||
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
/** PUT /v1/code/sessions/:id/worker/external_metadata — Report worker metadata (no-op) */
|
||||
app.put("/:id/worker/external_metadata", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
app.put(
|
||||
'/:id/worker/external_metadata',
|
||||
acceptCliHeaders,
|
||||
sessionIngressAuth,
|
||||
async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
// TUI's CCRClient calls this for metadata reporting. Accept and discard.
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
},
|
||||
)
|
||||
|
||||
/** POST /v1/code/sessions/:id/worker/events/delivery — Batch delivery tracking (no-op) */
|
||||
app.post("/:id/worker/events/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
app.post(
|
||||
'/:id/worker/events/delivery',
|
||||
acceptCliHeaders,
|
||||
sessionIngressAuth,
|
||||
async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
},
|
||||
)
|
||||
|
||||
/** POST /v1/code/sessions/:id/worker/events/:eventId/delivery — Delivery tracking (no-op) */
|
||||
app.post("/:id/worker/events/:eventId/delivery", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
// TUI's CCRClient reports event delivery status (received/processing/processed).
|
||||
// Accept and discard — event bus doesn't track per-event delivery.
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
app.post(
|
||||
'/:id/worker/events/:eventId/delivery',
|
||||
acceptCliHeaders,
|
||||
sessionIngressAuth,
|
||||
async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
if (!getSession(sessionId)) {
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
// TUI's CCRClient reports event delivery status (received/processing/processed).
|
||||
// Accept and discard — event bus doesn't track per-event delivery.
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
},
|
||||
)
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,105 +1,139 @@
|
||||
import { Hono } from "hono";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getSession, incrementEpoch, touchSession, updateSessionStatus } from "../../services/session";
|
||||
import { Hono } from 'hono'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import {
|
||||
getSession,
|
||||
incrementEpoch,
|
||||
touchSession,
|
||||
updateSessionStatus,
|
||||
} from '../../services/session'
|
||||
import {
|
||||
automationStatesEqual,
|
||||
getAutomationStateEventPayload,
|
||||
} from "../../services/automationState";
|
||||
import { apiKeyAuth, acceptCliHeaders, sessionIngressAuth } from "../../auth/middleware";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from "../../store";
|
||||
} from '../../services/automationState'
|
||||
import {
|
||||
apiKeyAuth,
|
||||
acceptCliHeaders,
|
||||
sessionIngressAuth,
|
||||
} from '../../auth/middleware'
|
||||
import { getEventBus } from '../../transport/event-bus'
|
||||
import { storeGetSessionWorker, storeUpsertSessionWorker } from '../../store'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** GET /v1/code/sessions/:id/worker — Read worker state */
|
||||
app.get("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
app.get('/:id/worker', acceptCliHeaders, sessionIngressAuth, async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
const worker = storeGetSessionWorker(sessionId);
|
||||
return c.json({
|
||||
worker: {
|
||||
worker_status: worker?.workerStatus ?? session.status,
|
||||
external_metadata: worker?.externalMetadata ?? null,
|
||||
requires_action_details: worker?.requiresActionDetails ?? null,
|
||||
last_heartbeat_at: worker?.lastHeartbeatAt?.toISOString() ?? null,
|
||||
const worker = storeGetSessionWorker(sessionId)
|
||||
return c.json(
|
||||
{
|
||||
worker: {
|
||||
worker_status: worker?.workerStatus ?? session.status,
|
||||
external_metadata: worker?.externalMetadata ?? null,
|
||||
requires_action_details: worker?.requiresActionDetails ?? null,
|
||||
last_heartbeat_at: worker?.lastHeartbeatAt?.toISOString() ?? null,
|
||||
},
|
||||
},
|
||||
}, 200);
|
||||
});
|
||||
200,
|
||||
)
|
||||
})
|
||||
|
||||
/** PUT /v1/code/sessions/:id/worker — Update worker state */
|
||||
app.put("/:id/worker", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
app.put('/:id/worker', acceptCliHeaders, sessionIngressAuth, async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const body = await c.req.json()
|
||||
const prevAutomationState = getAutomationStateEventPayload(
|
||||
storeGetSessionWorker(sessionId)?.externalMetadata,
|
||||
);
|
||||
)
|
||||
if (body.worker_status) {
|
||||
updateSessionStatus(sessionId, body.worker_status);
|
||||
updateSessionStatus(sessionId, body.worker_status)
|
||||
} else {
|
||||
touchSession(sessionId);
|
||||
touchSession(sessionId)
|
||||
}
|
||||
|
||||
const worker = storeUpsertSessionWorker(sessionId, {
|
||||
workerStatus: body.worker_status,
|
||||
externalMetadata: body.external_metadata,
|
||||
requiresActionDetails: body.requires_action_details,
|
||||
});
|
||||
const nextAutomationState = getAutomationStateEventPayload(worker.externalMetadata);
|
||||
})
|
||||
const nextAutomationState = getAutomationStateEventPayload(
|
||||
worker.externalMetadata,
|
||||
)
|
||||
|
||||
if (!automationStatesEqual(prevAutomationState, nextAutomationState)) {
|
||||
getEventBus(sessionId).publish({
|
||||
id: randomUUID(),
|
||||
sessionId,
|
||||
type: "automation_state",
|
||||
type: 'automation_state',
|
||||
payload: nextAutomationState,
|
||||
direction: "inbound",
|
||||
});
|
||||
direction: 'inbound',
|
||||
})
|
||||
}
|
||||
|
||||
return c.json({
|
||||
status: "ok",
|
||||
worker: {
|
||||
worker_status: worker.workerStatus ?? session.status,
|
||||
external_metadata: worker.externalMetadata,
|
||||
requires_action_details: worker.requiresActionDetails,
|
||||
last_heartbeat_at: worker.lastHeartbeatAt?.toISOString() ?? null,
|
||||
return c.json(
|
||||
{
|
||||
status: 'ok',
|
||||
worker: {
|
||||
worker_status: worker.workerStatus ?? session.status,
|
||||
external_metadata: worker.externalMetadata,
|
||||
requires_action_details: worker.requiresActionDetails,
|
||||
last_heartbeat_at: worker.lastHeartbeatAt?.toISOString() ?? null,
|
||||
},
|
||||
},
|
||||
}, 200);
|
||||
});
|
||||
200,
|
||||
)
|
||||
})
|
||||
|
||||
/** POST /v1/code/sessions/:id/worker/heartbeat — Keep worker alive */
|
||||
app.post("/:id/worker/heartbeat", acceptCliHeaders, sessionIngressAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
}
|
||||
app.post(
|
||||
'/:id/worker/heartbeat',
|
||||
acceptCliHeaders,
|
||||
sessionIngressAuth,
|
||||
async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
storeUpsertSessionWorker(sessionId, { lastHeartbeatAt: now });
|
||||
touchSession(sessionId);
|
||||
return c.json({ status: "ok", last_heartbeat_at: now.toISOString() }, 200);
|
||||
});
|
||||
const now = new Date()
|
||||
storeUpsertSessionWorker(sessionId, { lastHeartbeatAt: now })
|
||||
touchSession(sessionId)
|
||||
return c.json({ status: 'ok', last_heartbeat_at: now.toISOString() }, 200)
|
||||
},
|
||||
)
|
||||
|
||||
/** POST /v1/code/sessions/:id/worker/register — Register worker */
|
||||
app.post("/:id/worker/register", acceptCliHeaders, apiKeyAuth, async (c) => {
|
||||
const sessionId = c.req.param("id")!;
|
||||
const session = getSession(sessionId);
|
||||
app.post('/:id/worker/register', acceptCliHeaders, apiKeyAuth, async c => {
|
||||
const sessionId = c.req.param('id')!
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
const epoch = incrementEpoch(sessionId);
|
||||
return c.json({ worker_epoch: epoch }, 200);
|
||||
});
|
||||
const epoch = incrementEpoch(sessionId)
|
||||
return c.json({ worker_epoch: epoch }, 200)
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { Hono } from "hono";
|
||||
import { storeBindSession } from "../../store";
|
||||
import { resolveExistingWebSessionId, toWebSessionId } from "../../services/session";
|
||||
import { Hono } from 'hono'
|
||||
import { storeBindSession } from '../../store'
|
||||
import {
|
||||
resolveExistingWebSessionId,
|
||||
toWebSessionId,
|
||||
} from '../../services/session'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** POST /web/bind — Bind a session to a UUID (no-login auth) */
|
||||
app.post("/bind", async (c) => {
|
||||
const body = await c.req.json();
|
||||
const sessionId = body.sessionId;
|
||||
app.post('/bind', async c => {
|
||||
const body = await c.req.json()
|
||||
const sessionId = body.sessionId
|
||||
// UUID can come from query param (api.js sends it in URL) or body
|
||||
const uuid = c.req.query("uuid") || body.uuid;
|
||||
const uuid = c.req.query('uuid') || body.uuid
|
||||
|
||||
if (!sessionId || !uuid) {
|
||||
return c.json({ error: "sessionId and uuid are required" }, 400);
|
||||
return c.json({ error: 'sessionId and uuid are required' }, 400)
|
||||
}
|
||||
|
||||
const resolvedSessionId = resolveExistingWebSessionId(sessionId);
|
||||
const resolvedSessionId = resolveExistingWebSessionId(sessionId)
|
||||
if (!resolvedSessionId) {
|
||||
return c.json({ error: "Session not found" }, 404);
|
||||
return c.json({ error: 'Session not found' }, 404)
|
||||
}
|
||||
|
||||
storeBindSession(resolvedSessionId, uuid);
|
||||
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) });
|
||||
});
|
||||
storeBindSession(resolvedSessionId, uuid)
|
||||
return c.json({ ok: true, sessionId: toWebSessionId(resolvedSessionId) })
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,86 +1,130 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getSession, isSessionClosedStatus, resolveOwnedWebSessionId, updateSessionStatus } from "../../services/session";
|
||||
import { publishSessionEvent } from "../../services/transport";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
import { log, error as logError } from '../../logger'
|
||||
import { Hono } from 'hono'
|
||||
import { uuidAuth } from '../../auth/middleware'
|
||||
import {
|
||||
getSession,
|
||||
isSessionClosedStatus,
|
||||
resolveOwnedWebSessionId,
|
||||
updateSessionStatus,
|
||||
} from '../../services/session'
|
||||
import { publishSessionEvent } from '../../services/transport'
|
||||
import { getEventBus } from '../../transport/event-bus'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
type OwnershipCheckResult =
|
||||
| { error: true }
|
||||
| { error: true; reason: string }
|
||||
| { error: false; session: NonNullable<ReturnType<typeof getSession>>; sessionId: string };
|
||||
| {
|
||||
error: false
|
||||
session: NonNullable<ReturnType<typeof getSession>>
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
function checkOwnership(c: { get: (key: string) => string | undefined }, sessionId: string): OwnershipCheckResult {
|
||||
const uuid = c.get("uuid")!;
|
||||
const resolvedSessionId = resolveOwnedWebSessionId(sessionId, uuid);
|
||||
function checkOwnership(
|
||||
c: { get: (key: string) => string | undefined },
|
||||
sessionId: string,
|
||||
): OwnershipCheckResult {
|
||||
const uuid = c.get('uuid')!
|
||||
const resolvedSessionId = resolveOwnedWebSessionId(sessionId, uuid)
|
||||
if (!resolvedSessionId) {
|
||||
return { error: true };
|
||||
return { error: true }
|
||||
}
|
||||
const session = getSession(resolvedSessionId);
|
||||
const session = getSession(resolvedSessionId)
|
||||
if (!session) {
|
||||
return { error: true };
|
||||
return { error: true }
|
||||
}
|
||||
if (isSessionClosedStatus(session.status)) {
|
||||
return { error: true, reason: `Session is ${session.status}` };
|
||||
return { error: true, reason: `Session is ${session.status}` }
|
||||
}
|
||||
return { error: false, session, sessionId: resolvedSessionId };
|
||||
return { error: false, session, sessionId: resolvedSessionId }
|
||||
}
|
||||
|
||||
function closedSessionResponse(message: string) {
|
||||
return { error: { type: "session_closed", message } };
|
||||
return { error: { type: 'session_closed', message } }
|
||||
}
|
||||
|
||||
/** POST /web/sessions/:id/events — Send user message to session */
|
||||
app.post("/sessions/:id/events", uuidAuth, async (c) => {
|
||||
const requestedSessionId = c.req.param("id")!;
|
||||
const ownership = checkOwnership(c, requestedSessionId);
|
||||
app.post('/sessions/:id/events', uuidAuth, async c => {
|
||||
const requestedSessionId = c.req.param('id')!
|
||||
const ownership = checkOwnership(c, requestedSessionId)
|
||||
if (ownership.error) {
|
||||
const message = "reason" in ownership ? ownership.reason : "Not your session";
|
||||
const status = "reason" in ownership ? 409 : 403;
|
||||
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
|
||||
const message =
|
||||
'reason' in ownership ? ownership.reason : 'Not your session'
|
||||
const status = 'reason' in ownership ? 409 : 403
|
||||
return c.json(
|
||||
'reason' in ownership
|
||||
? closedSessionResponse(message)
|
||||
: { error: { type: 'forbidden', message } },
|
||||
status,
|
||||
)
|
||||
}
|
||||
const { sessionId } = ownership;
|
||||
const { sessionId } = ownership
|
||||
|
||||
const body = await c.req.json();
|
||||
const eventType = body.type || "user";
|
||||
log(`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`);
|
||||
const event = publishSessionEvent(sessionId, eventType, body, "outbound");
|
||||
log(`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`);
|
||||
return c.json({ status: "ok", event }, 200);
|
||||
});
|
||||
const body = await c.req.json()
|
||||
const eventType = body.type || 'user'
|
||||
log(
|
||||
`[RC-DEBUG] web -> server: POST /web/sessions/${sessionId}/events type=${eventType} content=${JSON.stringify(body).slice(0, 200)}`,
|
||||
)
|
||||
const event = publishSessionEvent(sessionId, eventType, body, 'outbound')
|
||||
log(
|
||||
`[RC-DEBUG] web -> server: published outbound event id=${event.id} type=${event.type} direction=${event.direction} subscribers=${getEventBus(sessionId).subscriberCount()}`,
|
||||
)
|
||||
return c.json({ status: 'ok', event }, 200)
|
||||
})
|
||||
|
||||
/** POST /web/sessions/:id/control — Send control request (permission approval etc) */
|
||||
app.post("/sessions/:id/control", uuidAuth, async (c) => {
|
||||
const requestedSessionId = c.req.param("id")!;
|
||||
const ownership = checkOwnership(c, requestedSessionId);
|
||||
app.post('/sessions/:id/control', uuidAuth, async c => {
|
||||
const requestedSessionId = c.req.param('id')!
|
||||
const ownership = checkOwnership(c, requestedSessionId)
|
||||
if (ownership.error) {
|
||||
const message = "reason" in ownership ? ownership.reason : "Not your session";
|
||||
const status = "reason" in ownership ? 409 : 403;
|
||||
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
|
||||
const message =
|
||||
'reason' in ownership ? ownership.reason : 'Not your session'
|
||||
const status = 'reason' in ownership ? 409 : 403
|
||||
return c.json(
|
||||
'reason' in ownership
|
||||
? closedSessionResponse(message)
|
||||
: { error: { type: 'forbidden', message } },
|
||||
status,
|
||||
)
|
||||
}
|
||||
const { sessionId } = ownership;
|
||||
const { sessionId } = ownership
|
||||
|
||||
const body = await c.req.json();
|
||||
const event = publishSessionEvent(sessionId, body.type || "control_request", body, "outbound");
|
||||
return c.json({ status: "ok", event }, 200);
|
||||
});
|
||||
const body = await c.req.json()
|
||||
const event = publishSessionEvent(
|
||||
sessionId,
|
||||
body.type || 'control_request',
|
||||
body,
|
||||
'outbound',
|
||||
)
|
||||
return c.json({ status: 'ok', event }, 200)
|
||||
})
|
||||
|
||||
/** POST /web/sessions/:id/interrupt — Interrupt session */
|
||||
app.post("/sessions/:id/interrupt", uuidAuth, async (c) => {
|
||||
const requestedSessionId = c.req.param("id")!;
|
||||
const ownership = checkOwnership(c, requestedSessionId);
|
||||
app.post('/sessions/:id/interrupt', uuidAuth, async c => {
|
||||
const requestedSessionId = c.req.param('id')!
|
||||
const ownership = checkOwnership(c, requestedSessionId)
|
||||
if (ownership.error) {
|
||||
const message = "reason" in ownership ? ownership.reason : "Not your session";
|
||||
const status = "reason" in ownership ? 409 : 403;
|
||||
return c.json("reason" in ownership ? closedSessionResponse(message) : { error: { type: "forbidden", message } }, status);
|
||||
const message =
|
||||
'reason' in ownership ? ownership.reason : 'Not your session'
|
||||
const status = 'reason' in ownership ? 409 : 403
|
||||
return c.json(
|
||||
'reason' in ownership
|
||||
? closedSessionResponse(message)
|
||||
: { error: { type: 'forbidden', message } },
|
||||
status,
|
||||
)
|
||||
}
|
||||
const { sessionId } = ownership;
|
||||
const { sessionId } = ownership
|
||||
|
||||
publishSessionEvent(sessionId, "interrupt", { action: "interrupt" }, "outbound");
|
||||
updateSessionStatus(sessionId, "idle");
|
||||
return c.json({ status: "ok" }, 200);
|
||||
});
|
||||
publishSessionEvent(
|
||||
sessionId,
|
||||
'interrupt',
|
||||
{ action: 'interrupt' },
|
||||
'outbound',
|
||||
)
|
||||
updateSessionStatus(sessionId, 'idle')
|
||||
return c.json({ status: 'ok' }, 200)
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { listActiveEnvironmentsResponse } from "../../services/environment";
|
||||
import { Hono } from 'hono'
|
||||
import { uuidAuth } from '../../auth/middleware'
|
||||
import { listActiveEnvironmentsResponse } from '../../services/environment'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** GET /web/environments — List active environments (UUID-based, no user filtering) */
|
||||
app.get("/environments", uuidAuth, async (c) => {
|
||||
app.get('/environments', uuidAuth, async c => {
|
||||
// Environments are shared across all UUIDs for now
|
||||
const envs = listActiveEnvironmentsResponse();
|
||||
return c.json(envs, 200);
|
||||
});
|
||||
const envs = listActiveEnvironmentsResponse()
|
||||
return c.json(envs, 200)
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { log, error as logError } from "../../logger";
|
||||
import { Hono } from "hono";
|
||||
import { uuidAuth } from "../../auth/middleware";
|
||||
import { getAutomationStateSnapshot } from "../../services/automationState";
|
||||
import { log, error as logError } from '../../logger'
|
||||
import { Hono } from 'hono'
|
||||
import { uuidAuth } from '../../auth/middleware'
|
||||
import { getAutomationStateSnapshot } from '../../services/automationState'
|
||||
import {
|
||||
createSession,
|
||||
getSession,
|
||||
@@ -10,109 +10,137 @@ import {
|
||||
listWebSessionsByOwnerUuid,
|
||||
resolveOwnedWebSessionId,
|
||||
toWebSessionResponse,
|
||||
} from "../../services/session";
|
||||
import { storeBindSession, storeGetSessionWorker } from "../../store";
|
||||
import { createWorkItem } from "../../services/work-dispatch";
|
||||
import { createSSEStream } from "../../transport/sse-writer";
|
||||
import { getEventBus } from "../../transport/event-bus";
|
||||
} from '../../services/session'
|
||||
import { storeBindSession, storeGetSessionWorker } from '../../store'
|
||||
import { createWorkItem } from '../../services/work-dispatch'
|
||||
import { createSSEStream } from '../../transport/sse-writer'
|
||||
import { getEventBus } from '../../transport/event-bus'
|
||||
|
||||
const app = new Hono();
|
||||
const app = new Hono()
|
||||
|
||||
/** POST /web/sessions — Create a session from web UI */
|
||||
app.post("/sessions", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const body = await c.req.json();
|
||||
app.post('/sessions', uuidAuth, async c => {
|
||||
const uuid = c.get('uuid')!
|
||||
const body = await c.req.json()
|
||||
const session = createSession({
|
||||
environment_id: body.environment_id || null,
|
||||
title: body.title || "New Session",
|
||||
source: "web",
|
||||
permission_mode: body.permission_mode || "default",
|
||||
});
|
||||
title: body.title || 'New Session',
|
||||
source: 'web',
|
||||
permission_mode: body.permission_mode || 'default',
|
||||
})
|
||||
|
||||
// Auto-bind to creator's UUID
|
||||
storeBindSession(session.id, uuid);
|
||||
storeBindSession(session.id, uuid)
|
||||
|
||||
// Dispatch work to environment if specified
|
||||
if (body.environment_id) {
|
||||
try {
|
||||
await createWorkItem(body.environment_id, session.id);
|
||||
await createWorkItem(body.environment_id, session.id)
|
||||
} catch (err) {
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`);
|
||||
logError(`[RCS] Failed to create work item: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(session, 200);
|
||||
});
|
||||
return c.json(session, 200)
|
||||
})
|
||||
|
||||
/** GET /web/sessions — List sessions owned by the requesting UUID */
|
||||
app.get("/sessions", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessions = listWebSessionsByOwnerUuid(uuid);
|
||||
return c.json(sessions, 200);
|
||||
});
|
||||
app.get('/sessions', uuidAuth, async c => {
|
||||
const uuid = c.get('uuid')!
|
||||
const sessions = listWebSessionsByOwnerUuid(uuid)
|
||||
return c.json(sessions, 200)
|
||||
})
|
||||
|
||||
/** GET /web/sessions/all — List sessions owned by the requesting UUID (unowned sessions excluded) */
|
||||
app.get("/sessions/all", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessions = listWebSessionSummariesByOwnerUuid(uuid);
|
||||
return c.json(sessions, 200);
|
||||
});
|
||||
app.get('/sessions/all', uuidAuth, async c => {
|
||||
const uuid = c.get('uuid')!
|
||||
const sessions = listWebSessionSummariesByOwnerUuid(uuid)
|
||||
return c.json(sessions, 200)
|
||||
})
|
||||
|
||||
/** GET /web/sessions/:id — Session detail */
|
||||
app.get("/sessions/:id", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
|
||||
app.get('/sessions/:id', uuidAuth, async c => {
|
||||
const uuid = c.get('uuid')!
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
|
||||
if (!sessionId) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
return c.json(
|
||||
{ error: { type: 'forbidden', message: 'Not your session' } },
|
||||
403,
|
||||
)
|
||||
}
|
||||
const session = getSession(sessionId);
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
const worker = storeGetSessionWorker(sessionId);
|
||||
const automationState = getAutomationStateSnapshot(worker?.externalMetadata);
|
||||
const response = toWebSessionResponse(session);
|
||||
const worker = storeGetSessionWorker(sessionId)
|
||||
const automationState = getAutomationStateSnapshot(worker?.externalMetadata)
|
||||
const response = toWebSessionResponse(session)
|
||||
return c.json(
|
||||
automationState === undefined ? response : { ...response, automation_state: automationState },
|
||||
automationState === undefined
|
||||
? response
|
||||
: { ...response, automation_state: automationState },
|
||||
200,
|
||||
);
|
||||
});
|
||||
)
|
||||
})
|
||||
|
||||
/** GET /web/sessions/:id/history — Historical events for session */
|
||||
app.get("/sessions/:id/history", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
|
||||
app.get('/sessions/:id/history', uuidAuth, async c => {
|
||||
const uuid = c.get('uuid')!
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
|
||||
if (!sessionId) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
return c.json(
|
||||
{ error: { type: 'forbidden', message: 'Not your session' } },
|
||||
403,
|
||||
)
|
||||
}
|
||||
const session = getSession(sessionId);
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
|
||||
const bus = getEventBus(sessionId);
|
||||
const events = bus.getEventsSince(0);
|
||||
return c.json({ events }, 200);
|
||||
});
|
||||
const bus = getEventBus(sessionId)
|
||||
const events = bus.getEventsSince(0)
|
||||
return c.json({ events }, 200)
|
||||
})
|
||||
|
||||
/** SSE /web/sessions/:id/events — Real-time event stream */
|
||||
app.get("/sessions/:id/events", uuidAuth, async (c) => {
|
||||
const uuid = c.get("uuid")!;
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param("id")!, uuid);
|
||||
app.get('/sessions/:id/events', uuidAuth, async c => {
|
||||
const uuid = c.get('uuid')!
|
||||
const sessionId = resolveOwnedWebSessionId(c.req.param('id')!, uuid)
|
||||
if (!sessionId) {
|
||||
return c.json({ error: { type: "forbidden", message: "Not your session" } }, 403);
|
||||
return c.json(
|
||||
{ error: { type: 'forbidden', message: 'Not your session' } },
|
||||
403,
|
||||
)
|
||||
}
|
||||
const session = getSession(sessionId);
|
||||
const session = getSession(sessionId)
|
||||
if (!session) {
|
||||
return c.json({ error: { type: "not_found", message: "Session not found" } }, 404);
|
||||
return c.json(
|
||||
{ error: { type: 'not_found', message: 'Session not found' } },
|
||||
404,
|
||||
)
|
||||
}
|
||||
if (isSessionClosedStatus(session.status)) {
|
||||
return c.json({ error: { type: "session_closed", message: `Session is ${session.status}` } }, 409);
|
||||
return c.json(
|
||||
{
|
||||
error: {
|
||||
type: 'session_closed',
|
||||
message: `Session is ${session.status}`,
|
||||
},
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
|
||||
const lastEventId = c.req.header("Last-Event-ID");
|
||||
const fromSeqNum = lastEventId ? parseInt(lastEventId) : 0;
|
||||
return createSSEStream(c, sessionId, fromSeqNum);
|
||||
});
|
||||
const lastEventId = c.req.header('Last-Event-ID')
|
||||
const fromSeqNum = lastEventId ? parseInt(lastEventId, 10) : 0
|
||||
return createSSEStream(c, sessionId, fromSeqNum)
|
||||
})
|
||||
|
||||
export default app;
|
||||
export default app
|
||||
|
||||
@@ -1,54 +1,66 @@
|
||||
import type { AutomationStateResponse } from "../types/api";
|
||||
import type { AutomationStateResponse } from '../types/api'
|
||||
|
||||
const DISABLED_AUTOMATION_STATE: AutomationStateResponse = Object.freeze({
|
||||
enabled: false,
|
||||
phase: null,
|
||||
next_tick_at: null,
|
||||
sleep_until: null,
|
||||
});
|
||||
})
|
||||
|
||||
function cloneAutomationState(state: AutomationStateResponse): AutomationStateResponse {
|
||||
return { ...state };
|
||||
function cloneAutomationState(
|
||||
state: AutomationStateResponse,
|
||||
): AutomationStateResponse {
|
||||
return { ...state }
|
||||
}
|
||||
|
||||
function normalizeAutomationState(raw: unknown): AutomationStateResponse {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return cloneAutomationState(DISABLED_AUTOMATION_STATE)
|
||||
}
|
||||
|
||||
const state = raw as Record<string, unknown>;
|
||||
const state = raw as Record<string, unknown>
|
||||
return {
|
||||
enabled: state.enabled === true,
|
||||
phase: state.phase === "standby" || state.phase === "sleeping" ? state.phase : null,
|
||||
next_tick_at: typeof state.next_tick_at === "number" ? state.next_tick_at : null,
|
||||
sleep_until: typeof state.sleep_until === "number" ? state.sleep_until : null,
|
||||
};
|
||||
phase:
|
||||
state.phase === 'standby' || state.phase === 'sleeping'
|
||||
? state.phase
|
||||
: null,
|
||||
next_tick_at:
|
||||
typeof state.next_tick_at === 'number' ? state.next_tick_at : null,
|
||||
sleep_until:
|
||||
typeof state.sleep_until === 'number' ? state.sleep_until : null,
|
||||
}
|
||||
}
|
||||
|
||||
function readAutomationStateValue(metadata: Record<string, unknown> | null | undefined): unknown {
|
||||
if (!metadata || typeof metadata !== "object") {
|
||||
return undefined;
|
||||
function readAutomationStateValue(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): unknown {
|
||||
if (!metadata || typeof metadata !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(metadata, "automation_state")) {
|
||||
return undefined;
|
||||
if (!Object.hasOwn(metadata, 'automation_state')) {
|
||||
return undefined
|
||||
}
|
||||
return metadata.automation_state;
|
||||
return metadata.automation_state
|
||||
}
|
||||
|
||||
export function getAutomationStateSnapshot(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse | undefined {
|
||||
const raw = readAutomationStateValue(metadata);
|
||||
const raw = readAutomationStateValue(metadata)
|
||||
if (raw === undefined) {
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
return normalizeAutomationState(raw);
|
||||
return normalizeAutomationState(raw)
|
||||
}
|
||||
|
||||
export function getAutomationStateEventPayload(
|
||||
metadata: Record<string, unknown> | null | undefined,
|
||||
): AutomationStateResponse {
|
||||
return getAutomationStateSnapshot(metadata) ?? cloneAutomationState(DISABLED_AUTOMATION_STATE);
|
||||
return (
|
||||
getAutomationStateSnapshot(metadata) ??
|
||||
cloneAutomationState(DISABLED_AUTOMATION_STATE)
|
||||
)
|
||||
}
|
||||
|
||||
export function automationStatesEqual(
|
||||
@@ -60,5 +72,5 @@ export function automationStatesEqual(
|
||||
a.phase === b.phase &&
|
||||
a.next_tick_at === b.next_tick_at &&
|
||||
a.sleep_until === b.sleep_until
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,47 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import { storeListActiveEnvironments, storeUpdateEnvironment, storeMarkAcpAgentOffline } from "../store";
|
||||
import { storeListSessions } from "../store";
|
||||
import { config } from "../config";
|
||||
import { updateSessionStatus } from "./session";
|
||||
import { log, error as logError } from '../logger'
|
||||
import {
|
||||
storeListActiveEnvironments,
|
||||
storeUpdateEnvironment,
|
||||
storeMarkAcpAgentOffline,
|
||||
} from '../store'
|
||||
import { storeListSessions } from '../store'
|
||||
import { config } from '../config'
|
||||
import { updateSessionStatus } from './session'
|
||||
|
||||
export function runDisconnectMonitorSweep(now = Date.now()) {
|
||||
const timeoutMs = config.disconnectTimeout * 1000;
|
||||
const timeoutMs = config.disconnectTimeout * 1000
|
||||
|
||||
// Check environment heartbeat timeout
|
||||
const envs = storeListActiveEnvironments();
|
||||
const envs = storeListActiveEnvironments()
|
||||
for (const env of envs) {
|
||||
// Skip ACP agents — they use WS keepalive, not polling
|
||||
if (env.workerType === "acp") {
|
||||
if (env.workerType === 'acp') {
|
||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||
log(`[RCS] ACP agent ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
storeMarkAcpAgentOffline(env.id);
|
||||
log(
|
||||
`[RCS] ACP agent ${env.id} timed out (no activity for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
|
||||
)
|
||||
storeMarkAcpAgentOffline(env.id)
|
||||
}
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
if (env.lastPollAt && now - env.lastPollAt.getTime() > timeoutMs) {
|
||||
log(`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`);
|
||||
storeUpdateEnvironment(env.id, { status: "disconnected" });
|
||||
log(
|
||||
`[RCS] Environment ${env.id} timed out (no poll for ${Math.round((now - env.lastPollAt.getTime()) / 1000)}s)`,
|
||||
)
|
||||
storeUpdateEnvironment(env.id, { status: 'disconnected' })
|
||||
}
|
||||
}
|
||||
|
||||
// Check session timeout (2x disconnect timeout with no update)
|
||||
const sessions = storeListSessions();
|
||||
const sessions = storeListSessions()
|
||||
for (const session of sessions) {
|
||||
if (session.status === "running" || session.status === "idle") {
|
||||
const elapsed = now - session.updatedAt.getTime();
|
||||
if (session.status === 'running' || session.status === 'idle') {
|
||||
const elapsed = now - session.updatedAt.getTime()
|
||||
if (elapsed > timeoutMs * 2) {
|
||||
log(`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`);
|
||||
updateSessionStatus(session.id, "inactive");
|
||||
log(
|
||||
`[RCS] Session ${session.id} marked inactive (no update for ${Math.round(elapsed / 1000)}s)`,
|
||||
)
|
||||
updateSessionStatus(session.id, 'inactive')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +49,6 @@ export function runDisconnectMonitorSweep(now = Date.now()) {
|
||||
|
||||
export function startDisconnectMonitor() {
|
||||
setInterval(() => {
|
||||
runDisconnectMonitorSweep();
|
||||
}, 60_000); // Check every minute
|
||||
runDisconnectMonitorSweep()
|
||||
}, 60_000) // Check every minute
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { config } from "../config";
|
||||
import { config } from '../config'
|
||||
import {
|
||||
storeCreateEnvironment,
|
||||
storeCreateSession,
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
storeListActiveEnvironments,
|
||||
storeListActiveEnvironmentsByUsername,
|
||||
storeListSessionsByEnvironment,
|
||||
} from "../store";
|
||||
import type { RegisterEnvironmentRequest, EnvironmentResponse } from "../types/api";
|
||||
import type { EnvironmentRecord } from "../store";
|
||||
} from '../store'
|
||||
import type {
|
||||
RegisterEnvironmentRequest,
|
||||
EnvironmentResponse,
|
||||
} from '../types/api'
|
||||
import type { EnvironmentRecord } from '../store'
|
||||
|
||||
function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
||||
return {
|
||||
@@ -23,12 +26,17 @@ function toResponse(row: EnvironmentRecord): EnvironmentResponse {
|
||||
worker_type: row.workerType,
|
||||
channel_group_id: row.bridgeId,
|
||||
capabilities: row.capabilities,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata?: { worker_type?: string }; username?: string }) {
|
||||
const secret = config.apiKeys[0] || "";
|
||||
const workerType = req.worker_type || req.metadata?.worker_type;
|
||||
export function registerEnvironment(
|
||||
req: RegisterEnvironmentRequest & {
|
||||
metadata?: { worker_type?: string }
|
||||
username?: string
|
||||
},
|
||||
) {
|
||||
const secret = config.apiKeys[0] || ''
|
||||
const workerType = req.worker_type || req.metadata?.worker_type
|
||||
const record = storeCreateEnvironment({
|
||||
secret,
|
||||
machineName: req.machine_name,
|
||||
@@ -40,51 +48,58 @@ export function registerEnvironment(req: RegisterEnvironmentRequest & { metadata
|
||||
bridgeId: req.bridge_id,
|
||||
username: req.username,
|
||||
capabilities: req.capabilities,
|
||||
});
|
||||
})
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let sessionId: string | undefined
|
||||
// ACP agents: reuse existing session or create one
|
||||
if (workerType === "acp") {
|
||||
const existing = storeListSessionsByEnvironment(record.id);
|
||||
if (workerType === 'acp') {
|
||||
const existing = storeListSessionsByEnvironment(record.id)
|
||||
if (existing.length > 0) {
|
||||
sessionId = existing[0].id;
|
||||
sessionId = existing[0].id
|
||||
} else {
|
||||
const session = storeCreateSession({
|
||||
environmentId: record.id,
|
||||
title: req.machine_name || "ACP Agent",
|
||||
source: "acp",
|
||||
});
|
||||
sessionId = session.id;
|
||||
title: req.machine_name || 'ACP Agent',
|
||||
source: 'acp',
|
||||
})
|
||||
sessionId = session.id
|
||||
}
|
||||
}
|
||||
|
||||
return { environment_id: record.id, environment_secret: record.secret, status: record.status as "active", session_id: sessionId };
|
||||
return {
|
||||
environment_id: record.id,
|
||||
environment_secret: record.secret,
|
||||
status: record.status as 'active',
|
||||
session_id: sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
export function deregisterEnvironment(envId: string) {
|
||||
storeUpdateEnvironment(envId, { status: "deregistered" });
|
||||
storeUpdateEnvironment(envId, { status: 'deregistered' })
|
||||
}
|
||||
|
||||
export function getEnvironment(envId: string) {
|
||||
return storeGetEnvironment(envId);
|
||||
return storeGetEnvironment(envId)
|
||||
}
|
||||
|
||||
export function updatePollTime(envId: string) {
|
||||
storeUpdateEnvironment(envId, { lastPollAt: new Date() });
|
||||
storeUpdateEnvironment(envId, { lastPollAt: new Date() })
|
||||
}
|
||||
|
||||
export function listActiveEnvironments() {
|
||||
return storeListActiveEnvironments();
|
||||
return storeListActiveEnvironments()
|
||||
}
|
||||
|
||||
export function listActiveEnvironmentsResponse(): EnvironmentResponse[] {
|
||||
return storeListActiveEnvironments().map(toResponse);
|
||||
return storeListActiveEnvironments().map(toResponse)
|
||||
}
|
||||
|
||||
export function listActiveEnvironmentsByUsername(username: string): EnvironmentResponse[] {
|
||||
return storeListActiveEnvironmentsByUsername(username).map(toResponse);
|
||||
export function listActiveEnvironmentsByUsername(
|
||||
username: string,
|
||||
): EnvironmentResponse[] {
|
||||
return storeListActiveEnvironmentsByUsername(username).map(toResponse)
|
||||
}
|
||||
|
||||
export function reconnectEnvironment(envId: string) {
|
||||
storeUpdateEnvironment(envId, { status: "active" });
|
||||
storeUpdateEnvironment(envId, { status: 'active' })
|
||||
}
|
||||
|
||||
@@ -9,16 +9,32 @@ import {
|
||||
storeListSessionsByUsername,
|
||||
storeListSessionsByEnvironment,
|
||||
storeListSessionsByOwnerUuid,
|
||||
} from "../store";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getAllEventBuses, removeEventBus } from "../transport/event-bus";
|
||||
import type { CreateSessionRequest, CreateCodeSessionRequest, SessionResponse, SessionSummaryResponse } from "../types/api";
|
||||
} from '../store'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getAllEventBuses, removeEventBus } from '../transport/event-bus'
|
||||
import type {
|
||||
CreateSessionRequest,
|
||||
CreateCodeSessionRequest,
|
||||
SessionResponse,
|
||||
SessionSummaryResponse,
|
||||
} from '../types/api'
|
||||
|
||||
const CODE_SESSION_PREFIX = "cse_";
|
||||
const WEB_SESSION_PREFIX = "session_";
|
||||
const CLOSED_SESSION_STATUSES = new Set(["archived", "inactive"]);
|
||||
const CODE_SESSION_PREFIX = 'cse_'
|
||||
const WEB_SESSION_PREFIX = 'session_'
|
||||
const CLOSED_SESSION_STATUSES = new Set(['archived', 'inactive'])
|
||||
|
||||
function toResponse(row: { id: string; environmentId: string | null; title: string | null; status: string; source: string; permissionMode: string | null; workerEpoch: number; username: string | null; createdAt: Date; updatedAt: Date }): SessionResponse {
|
||||
function toResponse(row: {
|
||||
id: string
|
||||
environmentId: string | null
|
||||
title: string | null
|
||||
status: string
|
||||
source: string
|
||||
permissionMode: string | null
|
||||
workerEpoch: number
|
||||
username: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}): SessionResponse {
|
||||
return {
|
||||
id: row.id,
|
||||
environment_id: row.environmentId,
|
||||
@@ -30,172 +46,200 @@ function toResponse(row: { id: string; environmentId: string | null; title: stri
|
||||
username: row.username,
|
||||
created_at: row.createdAt.getTime() / 1000,
|
||||
updated_at: row.updatedAt.getTime() / 1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function toWebSessionId(sessionId: string): string {
|
||||
if (!sessionId.startsWith(CODE_SESSION_PREFIX)) return sessionId;
|
||||
return `${WEB_SESSION_PREFIX}${sessionId.slice(CODE_SESSION_PREFIX.length)}`;
|
||||
if (!sessionId.startsWith(CODE_SESSION_PREFIX)) return sessionId
|
||||
return `${WEB_SESSION_PREFIX}${sessionId.slice(CODE_SESSION_PREFIX.length)}`
|
||||
}
|
||||
|
||||
function toCompatibleCodeSessionId(sessionId: string): string | null {
|
||||
if (!sessionId.startsWith(WEB_SESSION_PREFIX)) return null;
|
||||
return `${CODE_SESSION_PREFIX}${sessionId.slice(WEB_SESSION_PREFIX.length)}`;
|
||||
if (!sessionId.startsWith(WEB_SESSION_PREFIX)) return null
|
||||
return `${CODE_SESSION_PREFIX}${sessionId.slice(WEB_SESSION_PREFIX.length)}`
|
||||
}
|
||||
|
||||
export function toWebSessionResponse(session: SessionResponse): SessionResponse {
|
||||
return { ...session, id: toWebSessionId(session.id) };
|
||||
export function toWebSessionResponse(
|
||||
session: SessionResponse,
|
||||
): SessionResponse {
|
||||
return { ...session, id: toWebSessionId(session.id) }
|
||||
}
|
||||
|
||||
function toWebSessionSummaryResponse(session: SessionSummaryResponse): SessionSummaryResponse {
|
||||
return { ...session, id: toWebSessionId(session.id) };
|
||||
function toWebSessionSummaryResponse(
|
||||
session: SessionSummaryResponse,
|
||||
): SessionSummaryResponse {
|
||||
return { ...session, id: toWebSessionId(session.id) }
|
||||
}
|
||||
|
||||
export function createSession(req: CreateSessionRequest & { username?: string }): SessionResponse {
|
||||
export function createSession(
|
||||
req: CreateSessionRequest & { username?: string },
|
||||
): SessionResponse {
|
||||
const record = storeCreateSession({
|
||||
environmentId: req.environment_id,
|
||||
title: req.title,
|
||||
source: req.source,
|
||||
permissionMode: req.permission_mode,
|
||||
username: req.username,
|
||||
});
|
||||
return toResponse(record);
|
||||
})
|
||||
return toResponse(record)
|
||||
}
|
||||
|
||||
export function createCodeSession(req: CreateCodeSessionRequest): SessionResponse {
|
||||
export function createCodeSession(
|
||||
req: CreateCodeSessionRequest,
|
||||
): SessionResponse {
|
||||
const record = storeCreateSession({
|
||||
idPrefix: "cse_",
|
||||
idPrefix: 'cse_',
|
||||
title: req.title,
|
||||
source: req.source,
|
||||
permissionMode: req.permission_mode,
|
||||
});
|
||||
return toResponse(record);
|
||||
})
|
||||
return toResponse(record)
|
||||
}
|
||||
|
||||
export function getSession(sessionId: string): SessionResponse | null {
|
||||
const record = storeGetSession(sessionId);
|
||||
return record ? toResponse(record) : null;
|
||||
const record = storeGetSession(sessionId)
|
||||
return record ? toResponse(record) : null
|
||||
}
|
||||
|
||||
export function isSessionClosedStatus(status: string | null | undefined): boolean {
|
||||
return !!status && CLOSED_SESSION_STATUSES.has(status);
|
||||
export function isSessionClosedStatus(
|
||||
status: string | null | undefined,
|
||||
): boolean {
|
||||
return !!status && CLOSED_SESSION_STATUSES.has(status)
|
||||
}
|
||||
|
||||
export function resolveExistingSessionId(sessionId: string): string | null {
|
||||
if (storeGetSession(sessionId)) {
|
||||
return sessionId;
|
||||
return sessionId
|
||||
}
|
||||
|
||||
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId);
|
||||
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId)
|
||||
if (compatibleCodeSessionId && storeGetSession(compatibleCodeSessionId)) {
|
||||
return compatibleCodeSessionId;
|
||||
return compatibleCodeSessionId
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
export function resolveExistingWebSessionId(sessionId: string): string | null {
|
||||
return resolveExistingSessionId(sessionId);
|
||||
return resolveExistingSessionId(sessionId)
|
||||
}
|
||||
|
||||
export function resolveOwnedWebSessionId(sessionId: string, uuid: string): string | null {
|
||||
export function resolveOwnedWebSessionId(
|
||||
sessionId: string,
|
||||
uuid: string,
|
||||
): string | null {
|
||||
if (storeIsSessionOwner(sessionId, uuid)) {
|
||||
return sessionId;
|
||||
return sessionId
|
||||
}
|
||||
|
||||
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId);
|
||||
if (compatibleCodeSessionId && storeIsSessionOwner(compatibleCodeSessionId, uuid)) {
|
||||
return compatibleCodeSessionId;
|
||||
const compatibleCodeSessionId = toCompatibleCodeSessionId(sessionId)
|
||||
if (
|
||||
compatibleCodeSessionId &&
|
||||
storeIsSessionOwner(compatibleCodeSessionId, uuid)
|
||||
) {
|
||||
return compatibleCodeSessionId
|
||||
}
|
||||
|
||||
// Auto-bind: if the session exists but has no owner, claim it for the requesting user
|
||||
const existingId = resolveExistingSessionId(sessionId);
|
||||
const existingId = resolveExistingSessionId(sessionId)
|
||||
if (existingId) {
|
||||
const owners = storeGetSessionOwners(existingId);
|
||||
const owners = storeGetSessionOwners(existingId)
|
||||
if (!owners || owners.size === 0) {
|
||||
storeBindSession(existingId, uuid);
|
||||
return existingId;
|
||||
storeBindSession(existingId, uuid)
|
||||
return existingId
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
export function listWebSessionsByOwnerUuid(uuid: string): SessionResponse[] {
|
||||
return storeListSessionsByOwnerUuid(uuid)
|
||||
.filter((session) => !isSessionClosedStatus(session.status))
|
||||
.filter(session => !isSessionClosedStatus(session.status))
|
||||
.map(toResponse)
|
||||
.map(toWebSessionResponse);
|
||||
.map(toWebSessionResponse)
|
||||
}
|
||||
|
||||
export function listWebSessionSummariesByOwnerUuid(uuid: string): SessionSummaryResponse[] {
|
||||
export function listWebSessionSummariesByOwnerUuid(
|
||||
uuid: string,
|
||||
): SessionSummaryResponse[] {
|
||||
return storeListSessionsByOwnerUuid(uuid)
|
||||
.filter((session) => !isSessionClosedStatus(session.status))
|
||||
.filter(session => !isSessionClosedStatus(session.status))
|
||||
.map(toSummaryResponse)
|
||||
.map(toWebSessionSummaryResponse);
|
||||
.map(toWebSessionSummaryResponse)
|
||||
}
|
||||
|
||||
export function updateSessionTitle(sessionId: string, title: string) {
|
||||
storeUpdateSession(sessionId, { title });
|
||||
storeUpdateSession(sessionId, { title })
|
||||
}
|
||||
|
||||
export function updateSessionStatus(sessionId: string, status: string) {
|
||||
storeUpdateSession(sessionId, { status });
|
||||
const bus = getAllEventBuses().get(sessionId);
|
||||
if (!bus) return;
|
||||
storeUpdateSession(sessionId, { status })
|
||||
const bus = getAllEventBuses().get(sessionId)
|
||||
if (!bus) return
|
||||
|
||||
bus.publish({
|
||||
id: randomUUID(),
|
||||
sessionId,
|
||||
type: "session_status",
|
||||
type: 'session_status',
|
||||
payload: { status },
|
||||
direction: "inbound",
|
||||
});
|
||||
direction: 'inbound',
|
||||
})
|
||||
}
|
||||
|
||||
export function touchSession(sessionId: string) {
|
||||
storeUpdateSession(sessionId, {});
|
||||
storeUpdateSession(sessionId, {})
|
||||
}
|
||||
|
||||
export function archiveSession(sessionId: string) {
|
||||
updateSessionStatus(sessionId, "archived");
|
||||
removeEventBus(sessionId);
|
||||
updateSessionStatus(sessionId, 'archived')
|
||||
removeEventBus(sessionId)
|
||||
}
|
||||
|
||||
export function incrementEpoch(sessionId: string): number {
|
||||
const record = storeGetSession(sessionId);
|
||||
if (!record) throw new Error("Session not found");
|
||||
const newEpoch = record.workerEpoch + 1;
|
||||
storeUpdateSession(sessionId, { workerEpoch: newEpoch });
|
||||
return newEpoch;
|
||||
const record = storeGetSession(sessionId)
|
||||
if (!record) throw new Error('Session not found')
|
||||
const newEpoch = record.workerEpoch + 1
|
||||
storeUpdateSession(sessionId, { workerEpoch: newEpoch })
|
||||
return newEpoch
|
||||
}
|
||||
|
||||
export function listSessions() {
|
||||
return storeListSessions().map(toResponse);
|
||||
return storeListSessions().map(toResponse)
|
||||
}
|
||||
|
||||
function toSummaryResponse(row: { id: string; title: string | null; status: string; username: string | null; updatedAt: Date }): SessionSummaryResponse {
|
||||
function toSummaryResponse(row: {
|
||||
id: string
|
||||
title: string | null
|
||||
status: string
|
||||
username: string | null
|
||||
updatedAt: Date
|
||||
}): SessionSummaryResponse {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
username: row.username,
|
||||
updated_at: row.updatedAt.getTime() / 1000,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function listSessionSummaries(): SessionSummaryResponse[] {
|
||||
return storeListSessions().map(toSummaryResponse);
|
||||
return storeListSessions().map(toSummaryResponse)
|
||||
}
|
||||
|
||||
export function listSessionSummariesByOwnerUuid(uuid: string): SessionSummaryResponse[] {
|
||||
return storeListSessionsByOwnerUuid(uuid).map(toSummaryResponse);
|
||||
export function listSessionSummariesByOwnerUuid(
|
||||
uuid: string,
|
||||
): SessionSummaryResponse[] {
|
||||
return storeListSessionsByOwnerUuid(uuid).map(toSummaryResponse)
|
||||
}
|
||||
|
||||
export function listSessionSummariesByUsername(username: string): SessionSummaryResponse[] {
|
||||
return storeListSessionsByUsername(username).map(toSummaryResponse);
|
||||
export function listSessionSummariesByUsername(
|
||||
username: string,
|
||||
): SessionSummaryResponse[] {
|
||||
return storeListSessionsByUsername(username).map(toSummaryResponse)
|
||||
}
|
||||
|
||||
export function listSessionsByEnvironment(envId: string) {
|
||||
return storeListSessionsByEnvironment(envId).map(toResponse);
|
||||
return storeListSessionsByEnvironment(envId).map(toResponse)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getEventBus } from "../transport/event-bus";
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getEventBus } from '../transport/event-bus'
|
||||
|
||||
/**
|
||||
* Extract plain text from various message payload formats.
|
||||
@@ -9,75 +9,87 @@ import { getEventBus } from "../transport/event-bus";
|
||||
* { message: { content: [{type:"text",text:"..."}] } }
|
||||
*/
|
||||
function extractContent(payload: unknown): string {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return typeof payload === "string" ? payload : "";
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return typeof payload === 'string' ? payload : ''
|
||||
}
|
||||
|
||||
const p = payload as Record<string, unknown>;
|
||||
const p = payload as Record<string, unknown>
|
||||
|
||||
// Direct content field
|
||||
if (typeof p.content === "string" && p.content) return p.content;
|
||||
if (typeof p.content === 'string' && p.content) return p.content
|
||||
|
||||
// message.content (child process format)
|
||||
const msg = p.message;
|
||||
if (msg && typeof msg === "object") {
|
||||
const mc = (msg as Record<string, unknown>).content;
|
||||
if (typeof mc === "string") return mc;
|
||||
const msg = p.message
|
||||
if (msg && typeof msg === 'object') {
|
||||
const mc = (msg as Record<string, unknown>).content
|
||||
if (typeof mc === 'string') return mc
|
||||
if (Array.isArray(mc)) {
|
||||
return mc
|
||||
.filter((b: unknown) => typeof b === "object" && b !== null && (b as Record<string, unknown>).type === "text")
|
||||
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
|
||||
.join("");
|
||||
.filter(
|
||||
(b: unknown) =>
|
||||
typeof b === 'object' &&
|
||||
b !== null &&
|
||||
(b as Record<string, unknown>).type === 'text',
|
||||
)
|
||||
.map(
|
||||
(b: Record<string, unknown>) =>
|
||||
(b as Record<string, unknown>).text || '',
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize event payload into a flat structure with guaranteed `content` string.
|
||||
* Preserves original payload in `raw` field and keeps tool-specific fields.
|
||||
*/
|
||||
export function normalizePayload(type: string, payload: unknown): Record<string, unknown> {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return { content: typeof payload === "string" ? payload : "", raw: payload };
|
||||
export function normalizePayload(
|
||||
type: string,
|
||||
payload: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { content: typeof payload === 'string' ? payload : '', raw: payload }
|
||||
}
|
||||
|
||||
const p = payload as Record<string, unknown>;
|
||||
const content = extractContent(payload);
|
||||
const p = payload as Record<string, unknown>
|
||||
const content = extractContent(payload)
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
content,
|
||||
raw: payload,
|
||||
};
|
||||
|
||||
if (typeof p.uuid === "string" && p.uuid) normalized.uuid = p.uuid;
|
||||
if (typeof p.isSynthetic === "boolean") normalized.isSynthetic = p.isSynthetic;
|
||||
if (typeof p.status === "string") normalized.status = p.status;
|
||||
if (typeof p.subtype === "string") normalized.subtype = p.subtype;
|
||||
|
||||
// Preserve tool fields
|
||||
if (p.tool_name) normalized.tool_name = p.tool_name;
|
||||
if (p.name) normalized.tool_name = p.name;
|
||||
if (p.tool_input) normalized.tool_input = p.tool_input;
|
||||
if (p.input) normalized.tool_input = p.input;
|
||||
|
||||
// Preserve permission fields
|
||||
if (p.request_id) normalized.request_id = p.request_id;
|
||||
if (p.request) normalized.request = p.request;
|
||||
if (p.approved !== undefined) normalized.approved = p.approved;
|
||||
if (p.updated_input) normalized.updated_input = p.updated_input;
|
||||
|
||||
// Preserve message field for backward compat
|
||||
if (p.message) normalized.message = p.message;
|
||||
|
||||
if (type === "task_state") {
|
||||
if (typeof p.task_list_id === "string") normalized.task_list_id = p.task_list_id;
|
||||
if (typeof p.taskListId === "string") normalized.taskListId = p.taskListId;
|
||||
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
if (typeof p.uuid === 'string' && p.uuid) normalized.uuid = p.uuid
|
||||
if (typeof p.isSynthetic === 'boolean') normalized.isSynthetic = p.isSynthetic
|
||||
if (typeof p.status === 'string') normalized.status = p.status
|
||||
if (typeof p.subtype === 'string') normalized.subtype = p.subtype
|
||||
|
||||
// Preserve tool fields
|
||||
if (p.tool_name) normalized.tool_name = p.tool_name
|
||||
if (p.name) normalized.tool_name = p.name
|
||||
if (p.tool_input) normalized.tool_input = p.tool_input
|
||||
if (p.input) normalized.tool_input = p.input
|
||||
|
||||
// Preserve permission fields
|
||||
if (p.request_id) normalized.request_id = p.request_id
|
||||
if (p.request) normalized.request = p.request
|
||||
if (p.approved !== undefined) normalized.approved = p.approved
|
||||
if (p.updated_input) normalized.updated_input = p.updated_input
|
||||
|
||||
// Preserve message field for backward compat
|
||||
if (p.message) normalized.message = p.message
|
||||
|
||||
if (type === 'task_state') {
|
||||
if (typeof p.task_list_id === 'string')
|
||||
normalized.task_list_id = p.task_list_id
|
||||
if (typeof p.taskListId === 'string') normalized.taskListId = p.taskListId
|
||||
if (Array.isArray(p.tasks)) normalized.tasks = p.tasks
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
/** Publish an event to a session's bus (in-memory only) */
|
||||
@@ -85,12 +97,12 @@ export function publishSessionEvent(
|
||||
sessionId: string,
|
||||
type: string,
|
||||
payload: unknown,
|
||||
direction: "inbound" | "outbound",
|
||||
direction: 'inbound' | 'outbound',
|
||||
) {
|
||||
const bus = getEventBus(sessionId);
|
||||
const eventId = randomUUID();
|
||||
const bus = getEventBus(sessionId)
|
||||
const eventId = randomUUID()
|
||||
|
||||
const normalized = normalizePayload(type, payload);
|
||||
const normalized = normalizePayload(type, payload)
|
||||
|
||||
const event = bus.publish({
|
||||
id: eventId,
|
||||
@@ -98,7 +110,7 @@ export function publishSessionEvent(
|
||||
type,
|
||||
payload: normalized,
|
||||
direction,
|
||||
});
|
||||
})
|
||||
|
||||
return event;
|
||||
return event
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import { log, error as logError } from '../logger'
|
||||
import {
|
||||
storeCreateWorkItem,
|
||||
storeGetWorkItem,
|
||||
@@ -6,94 +6,111 @@ import {
|
||||
storeUpdateWorkItem,
|
||||
storeListSessionsByEnvironment,
|
||||
storeGetEnvironment,
|
||||
} from "../store";
|
||||
import { config } from "../config";
|
||||
import { getBaseUrl } from "../config";
|
||||
import type { WorkResponse } from "../types/api";
|
||||
} from '../store'
|
||||
import { config } from '../config'
|
||||
import { getBaseUrl } from '../config'
|
||||
import type { WorkResponse } from '../types/api'
|
||||
|
||||
/** Encode work secret as base64 JSON (no JWT — just API key as token) */
|
||||
function encodeWorkSecret(): string {
|
||||
const payload = {
|
||||
version: 1,
|
||||
session_ingress_token: config.apiKeys[0] || "",
|
||||
session_ingress_token: config.apiKeys[0] || '',
|
||||
api_base_url: getBaseUrl(),
|
||||
sources: [] as string[],
|
||||
auth: [] as string[],
|
||||
use_code_sessions: false,
|
||||
};
|
||||
return Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
}
|
||||
return Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
}
|
||||
|
||||
export async function createWorkItem(environmentId: string, sessionId: string): Promise<string> {
|
||||
export async function createWorkItem(
|
||||
environmentId: string,
|
||||
sessionId: string,
|
||||
): Promise<string> {
|
||||
// Validate environment exists and is active
|
||||
const env = storeGetEnvironment(environmentId);
|
||||
const env = storeGetEnvironment(environmentId)
|
||||
if (!env) {
|
||||
throw new Error(`Environment ${environmentId} not found`);
|
||||
throw new Error(`Environment ${environmentId} not found`)
|
||||
}
|
||||
if (env.status !== "active") {
|
||||
throw new Error(`Environment ${environmentId} is not active (status: ${env.status})`);
|
||||
if (env.status !== 'active') {
|
||||
throw new Error(
|
||||
`Environment ${environmentId} is not active (status: ${env.status})`,
|
||||
)
|
||||
}
|
||||
|
||||
const secret = encodeWorkSecret();
|
||||
const record = storeCreateWorkItem({ environmentId, sessionId, secret });
|
||||
log(`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`);
|
||||
return record.id;
|
||||
const secret = encodeWorkSecret()
|
||||
const record = storeCreateWorkItem({ environmentId, sessionId, secret })
|
||||
log(
|
||||
`[RCS] Work item created: ${record.id} for env=${environmentId} session=${sessionId}`,
|
||||
)
|
||||
return record.id
|
||||
}
|
||||
|
||||
/** Long-poll for work — blocks until work is available or timeout.
|
||||
* Returns null when no work is available, matching the CLI bridge client protocol. */
|
||||
export async function pollWork(environmentId: string, timeoutSeconds = config.pollTimeout): Promise<WorkResponse | null> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
export async function pollWork(
|
||||
environmentId: string,
|
||||
timeoutSeconds = config.pollTimeout,
|
||||
): Promise<WorkResponse | null> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const item = storeGetPendingWorkItem(environmentId);
|
||||
const item = storeGetPendingWorkItem(environmentId)
|
||||
|
||||
if (item) {
|
||||
storeUpdateWorkItem(item.id, { state: "dispatched" });
|
||||
storeUpdateWorkItem(item.id, { state: 'dispatched' })
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
type: "work",
|
||||
type: 'work',
|
||||
environment_id: environmentId,
|
||||
state: "dispatched",
|
||||
state: 'dispatched',
|
||||
data: {
|
||||
type: "session",
|
||||
type: 'session',
|
||||
id: item.sessionId,
|
||||
},
|
||||
secret: item.secret,
|
||||
created_at: item.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
export function ackWork(workId: string) {
|
||||
storeUpdateWorkItem(workId, { state: "acked" });
|
||||
storeUpdateWorkItem(workId, { state: 'acked' })
|
||||
}
|
||||
|
||||
export function stopWork(workId: string) {
|
||||
storeUpdateWorkItem(workId, { state: "completed" });
|
||||
storeUpdateWorkItem(workId, { state: 'completed' })
|
||||
}
|
||||
|
||||
export function heartbeatWork(workId: string): { lease_extended: boolean; state: string; last_heartbeat: string; ttl_seconds: number } {
|
||||
storeUpdateWorkItem(workId, {} as any); // just bump updatedAt
|
||||
const item = storeGetWorkItem(workId);
|
||||
const now = new Date();
|
||||
export function heartbeatWork(workId: string): {
|
||||
lease_extended: boolean
|
||||
state: string
|
||||
last_heartbeat: string
|
||||
ttl_seconds: number
|
||||
} {
|
||||
storeUpdateWorkItem(workId, {} as any) // just bump updatedAt
|
||||
const item = storeGetWorkItem(workId)
|
||||
const now = new Date()
|
||||
return {
|
||||
lease_extended: true,
|
||||
state: item?.state ?? "acked",
|
||||
state: item?.state ?? 'acked',
|
||||
last_heartbeat: now.toISOString(),
|
||||
ttl_seconds: config.heartbeatInterval * 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Reconnect: re-queue sessions associated with an environment */
|
||||
export function reconnectWorkForEnvironment(envId: string) {
|
||||
const activeSessions = storeListSessionsByEnvironment(envId).filter((s) => s.status === "idle");
|
||||
const promises = activeSessions.map((s) => createWorkItem(envId, s.id));
|
||||
return Promise.all(promises);
|
||||
const activeSessions = storeListSessionsByEnvironment(envId).filter(
|
||||
s => s.status === 'idle',
|
||||
)
|
||||
const promises = activeSessions.map(s => createWorkItem(envId, s.id))
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
@@ -1,117 +1,119 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
// ---------- Types ----------
|
||||
|
||||
export interface UserRecord {
|
||||
username: string;
|
||||
createdAt: Date;
|
||||
username: string
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface EnvironmentRecord {
|
||||
id: string;
|
||||
secret: string;
|
||||
machineName: string | null;
|
||||
directory: string | null;
|
||||
branch: string | null;
|
||||
gitRepoUrl: string | null;
|
||||
maxSessions: number;
|
||||
workerType: string;
|
||||
bridgeId: string | null;
|
||||
capabilities: Record<string, unknown> | null;
|
||||
status: string;
|
||||
username: string | null;
|
||||
lastPollAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string
|
||||
secret: string
|
||||
machineName: string | null
|
||||
directory: string | null
|
||||
branch: string | null
|
||||
gitRepoUrl: string | null
|
||||
maxSessions: number
|
||||
workerType: string
|
||||
bridgeId: string | null
|
||||
capabilities: Record<string, unknown> | null
|
||||
status: string
|
||||
username: string | null
|
||||
lastPollAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
environmentId: string | null;
|
||||
title: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
permissionMode: string | null;
|
||||
workerEpoch: number;
|
||||
username: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string
|
||||
environmentId: string | null
|
||||
title: string | null
|
||||
status: string
|
||||
source: string
|
||||
permissionMode: string | null
|
||||
workerEpoch: number
|
||||
username: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface WorkItemRecord {
|
||||
id: string;
|
||||
environmentId: string;
|
||||
sessionId: string;
|
||||
state: string;
|
||||
secret: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
id: string
|
||||
environmentId: string
|
||||
sessionId: string
|
||||
state: string
|
||||
secret: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface SessionWorkerRecord {
|
||||
sessionId: string;
|
||||
workerStatus: string | null;
|
||||
externalMetadata: Record<string, unknown> | null;
|
||||
requiresActionDetails: Record<string, unknown> | null;
|
||||
lastHeartbeatAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
sessionId: string
|
||||
workerStatus: string | null
|
||||
externalMetadata: Record<string, unknown> | null
|
||||
requiresActionDetails: Record<string, unknown> | null
|
||||
lastHeartbeatAt: Date | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
// ---------- Stores (in-memory Maps) ----------
|
||||
|
||||
const users = new Map<string, UserRecord>();
|
||||
const tokenToUser = new Map<string, { username: string; createdAt: Date }>();
|
||||
const environments = new Map<string, EnvironmentRecord>();
|
||||
const sessions = new Map<string, SessionRecord>();
|
||||
const workItems = new Map<string, WorkItemRecord>();
|
||||
const sessionWorkers = new Map<string, SessionWorkerRecord>();
|
||||
const users = new Map<string, UserRecord>()
|
||||
const tokenToUser = new Map<string, { username: string; createdAt: Date }>()
|
||||
const environments = new Map<string, EnvironmentRecord>()
|
||||
const sessions = new Map<string, SessionRecord>()
|
||||
const workItems = new Map<string, WorkItemRecord>()
|
||||
const sessionWorkers = new Map<string, SessionWorkerRecord>()
|
||||
|
||||
// UUID → session ownership: sessionId → Set of UUIDs
|
||||
const sessionOwners = new Map<string, Set<string>>();
|
||||
const sessionOwners = new Map<string, Set<string>>()
|
||||
|
||||
// ---------- User ----------
|
||||
|
||||
export function storeCreateUser(username: string): UserRecord {
|
||||
const existing = users.get(username);
|
||||
if (existing) return existing;
|
||||
const record: UserRecord = { username, createdAt: new Date() };
|
||||
users.set(username, record);
|
||||
return record;
|
||||
const existing = users.get(username)
|
||||
if (existing) return existing
|
||||
const record: UserRecord = { username, createdAt: new Date() }
|
||||
users.set(username, record)
|
||||
return record
|
||||
}
|
||||
|
||||
export function storeGetUser(username: string): UserRecord | undefined {
|
||||
return users.get(username);
|
||||
return users.get(username)
|
||||
}
|
||||
|
||||
export function storeCreateToken(username: string, token: string): void {
|
||||
tokenToUser.set(token, { username, createdAt: new Date() });
|
||||
tokenToUser.set(token, { username, createdAt: new Date() })
|
||||
}
|
||||
|
||||
export function storeGetUserByToken(token: string): { username: string; createdAt: Date } | undefined {
|
||||
return tokenToUser.get(token);
|
||||
export function storeGetUserByToken(
|
||||
token: string,
|
||||
): { username: string; createdAt: Date } | undefined {
|
||||
return tokenToUser.get(token)
|
||||
}
|
||||
|
||||
export function storeDeleteToken(token: string): boolean {
|
||||
return tokenToUser.delete(token);
|
||||
return tokenToUser.delete(token)
|
||||
}
|
||||
|
||||
// ---------- Environment ----------
|
||||
|
||||
export function storeCreateEnvironment(req: {
|
||||
secret: string;
|
||||
machineName?: string;
|
||||
directory?: string;
|
||||
branch?: string;
|
||||
gitRepoUrl?: string;
|
||||
maxSessions?: number;
|
||||
workerType?: string;
|
||||
bridgeId?: string;
|
||||
username?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
secret: string
|
||||
machineName?: string
|
||||
directory?: string
|
||||
branch?: string
|
||||
gitRepoUrl?: string
|
||||
maxSessions?: number
|
||||
workerType?: string
|
||||
bridgeId?: string
|
||||
username?: string
|
||||
capabilities?: Record<string, unknown>
|
||||
}): EnvironmentRecord {
|
||||
const id = `env_${randomUUID().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const id = `env_${randomUUID().replace(/-/g, '')}`
|
||||
const now = new Date()
|
||||
const record: EnvironmentRecord = {
|
||||
id,
|
||||
secret: req.secret,
|
||||
@@ -120,108 +122,136 @@ export function storeCreateEnvironment(req: {
|
||||
branch: req.branch ?? null,
|
||||
gitRepoUrl: req.gitRepoUrl ?? null,
|
||||
maxSessions: req.maxSessions ?? 1,
|
||||
workerType: req.workerType ?? "claude_code",
|
||||
workerType: req.workerType ?? 'claude_code',
|
||||
bridgeId: req.bridgeId ?? null,
|
||||
capabilities: req.capabilities ?? null,
|
||||
status: "active",
|
||||
status: 'active',
|
||||
username: req.username ?? null,
|
||||
lastPollAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
environments.set(id, record);
|
||||
return record;
|
||||
}
|
||||
environments.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
export function storeGetEnvironment(id: string): EnvironmentRecord | undefined {
|
||||
return environments.get(id);
|
||||
return environments.get(id)
|
||||
}
|
||||
|
||||
export function storeUpdateEnvironment(id: string, patch: Partial<Pick<EnvironmentRecord, "status" | "lastPollAt" | "updatedAt" | "capabilities" | "machineName" | "maxSessions" | "bridgeId">>): boolean {
|
||||
const rec = environments.get(id);
|
||||
if (!rec) return false;
|
||||
Object.assign(rec, patch, { updatedAt: new Date() });
|
||||
return true;
|
||||
export function storeUpdateEnvironment(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<
|
||||
EnvironmentRecord,
|
||||
| 'status'
|
||||
| 'lastPollAt'
|
||||
| 'updatedAt'
|
||||
| 'capabilities'
|
||||
| 'machineName'
|
||||
| 'maxSessions'
|
||||
| 'bridgeId'
|
||||
>
|
||||
>,
|
||||
): boolean {
|
||||
const rec = environments.get(id)
|
||||
if (!rec) return false
|
||||
Object.assign(rec, patch, { updatedAt: new Date() })
|
||||
return true
|
||||
}
|
||||
|
||||
export function storeListActiveEnvironments(): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter((e) => e.status === "active");
|
||||
return [...environments.values()].filter(e => e.status === 'active')
|
||||
}
|
||||
|
||||
export function storeListActiveEnvironmentsByUsername(username: string): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter((e) => e.status === "active" && e.username === username);
|
||||
export function storeListActiveEnvironmentsByUsername(
|
||||
username: string,
|
||||
): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter(
|
||||
e => e.status === 'active' && e.username === username,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Session ----------
|
||||
|
||||
export function storeCreateSession(req: {
|
||||
environmentId?: string | null;
|
||||
title?: string | null;
|
||||
source?: string;
|
||||
permissionMode?: string | null;
|
||||
idPrefix?: string;
|
||||
username?: string | null;
|
||||
environmentId?: string | null
|
||||
title?: string | null
|
||||
source?: string
|
||||
permissionMode?: string | null
|
||||
idPrefix?: string
|
||||
username?: string | null
|
||||
}): SessionRecord {
|
||||
const id = `${req.idPrefix || "session_"}${randomUUID().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const id = `${req.idPrefix || 'session_'}${randomUUID().replace(/-/g, '')}`
|
||||
const now = new Date()
|
||||
const record: SessionRecord = {
|
||||
id,
|
||||
environmentId: req.environmentId ?? null,
|
||||
title: req.title ?? null,
|
||||
status: "idle",
|
||||
source: req.source ?? "remote-control",
|
||||
status: 'idle',
|
||||
source: req.source ?? 'remote-control',
|
||||
permissionMode: req.permissionMode ?? null,
|
||||
workerEpoch: 0,
|
||||
username: req.username ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
sessions.set(id, record);
|
||||
return record;
|
||||
}
|
||||
sessions.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
export function storeGetSession(id: string): SessionRecord | undefined {
|
||||
return sessions.get(id);
|
||||
return sessions.get(id)
|
||||
}
|
||||
|
||||
export function storeUpdateSession(id: string, patch: Partial<Pick<SessionRecord, "title" | "status" | "workerEpoch" | "updatedAt">>): boolean {
|
||||
const rec = sessions.get(id);
|
||||
if (!rec) return false;
|
||||
Object.assign(rec, patch, { updatedAt: new Date() });
|
||||
return true;
|
||||
export function storeUpdateSession(
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Pick<SessionRecord, 'title' | 'status' | 'workerEpoch' | 'updatedAt'>
|
||||
>,
|
||||
): boolean {
|
||||
const rec = sessions.get(id)
|
||||
if (!rec) return false
|
||||
Object.assign(rec, patch, { updatedAt: new Date() })
|
||||
return true
|
||||
}
|
||||
|
||||
export function storeListSessions(): SessionRecord[] {
|
||||
return [...sessions.values()];
|
||||
return [...sessions.values()]
|
||||
}
|
||||
|
||||
export function storeListSessionsByUsername(username: string): SessionRecord[] {
|
||||
return [...sessions.values()].filter((s) => s.username === username);
|
||||
return [...sessions.values()].filter(s => s.username === username)
|
||||
}
|
||||
|
||||
export function storeListSessionsByEnvironment(envId: string): SessionRecord[] {
|
||||
return [...sessions.values()].filter((s) => s.environmentId === envId);
|
||||
return [...sessions.values()].filter(s => s.environmentId === envId)
|
||||
}
|
||||
|
||||
export function storeDeleteSession(id: string): boolean {
|
||||
sessionWorkers.delete(id);
|
||||
return sessions.delete(id);
|
||||
sessionWorkers.delete(id)
|
||||
return sessions.delete(id)
|
||||
}
|
||||
|
||||
// ---------- Session Worker ----------
|
||||
|
||||
export function storeGetSessionWorker(sessionId: string): SessionWorkerRecord | undefined {
|
||||
return sessionWorkers.get(sessionId);
|
||||
export function storeGetSessionWorker(
|
||||
sessionId: string,
|
||||
): SessionWorkerRecord | undefined {
|
||||
return sessionWorkers.get(sessionId)
|
||||
}
|
||||
|
||||
export function storeUpsertSessionWorker(sessionId: string, patch: {
|
||||
workerStatus?: string | null;
|
||||
externalMetadata?: Record<string, unknown> | null;
|
||||
requiresActionDetails?: Record<string, unknown> | null;
|
||||
lastHeartbeatAt?: Date | null;
|
||||
}): SessionWorkerRecord {
|
||||
const now = new Date();
|
||||
const existing = sessionWorkers.get(sessionId);
|
||||
export function storeUpsertSessionWorker(
|
||||
sessionId: string,
|
||||
patch: {
|
||||
workerStatus?: string | null
|
||||
externalMetadata?: Record<string, unknown> | null
|
||||
requiresActionDetails?: Record<string, unknown> | null
|
||||
lastHeartbeatAt?: Date | null
|
||||
},
|
||||
): SessionWorkerRecord {
|
||||
const now = new Date()
|
||||
const existing = sessionWorkers.get(sessionId)
|
||||
const record: SessionWorkerRecord = existing ?? {
|
||||
sessionId,
|
||||
workerStatus: null,
|
||||
@@ -230,31 +260,31 @@ export function storeUpsertSessionWorker(sessionId: string, patch: {
|
||||
lastHeartbeatAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
if (patch.workerStatus !== undefined) {
|
||||
record.workerStatus = patch.workerStatus;
|
||||
record.workerStatus = patch.workerStatus
|
||||
}
|
||||
if (patch.externalMetadata !== undefined) {
|
||||
if (patch.externalMetadata === null) {
|
||||
record.externalMetadata = null;
|
||||
record.externalMetadata = null
|
||||
} else {
|
||||
record.externalMetadata = {
|
||||
...(record.externalMetadata ?? {}),
|
||||
...patch.externalMetadata,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (patch.requiresActionDetails !== undefined) {
|
||||
record.requiresActionDetails = patch.requiresActionDetails;
|
||||
record.requiresActionDetails = patch.requiresActionDetails
|
||||
}
|
||||
if (patch.lastHeartbeatAt !== undefined) {
|
||||
record.lastHeartbeatAt = patch.lastHeartbeatAt;
|
||||
record.lastHeartbeatAt = patch.lastHeartbeatAt
|
||||
}
|
||||
record.updatedAt = now;
|
||||
record.updatedAt = now
|
||||
|
||||
sessionWorkers.set(sessionId, record);
|
||||
return record;
|
||||
sessionWorkers.set(sessionId, record)
|
||||
return record
|
||||
}
|
||||
|
||||
// ---------- Work Items ----------
|
||||
@@ -262,141 +292,154 @@ export function storeUpsertSessionWorker(sessionId: string, patch: {
|
||||
// ---------- Session Ownership (UUID-based) ----------
|
||||
|
||||
export function storeBindSession(sessionId: string, uuid: string): void {
|
||||
let owners = sessionOwners.get(sessionId);
|
||||
let owners = sessionOwners.get(sessionId)
|
||||
if (!owners) {
|
||||
owners = new Set();
|
||||
sessionOwners.set(sessionId, owners);
|
||||
owners = new Set()
|
||||
sessionOwners.set(sessionId, owners)
|
||||
}
|
||||
owners.add(uuid);
|
||||
owners.add(uuid)
|
||||
}
|
||||
|
||||
export function storeIsSessionOwner(sessionId: string, uuid: string): boolean {
|
||||
const owners = sessionOwners.get(sessionId);
|
||||
return owners ? owners.has(uuid) : false;
|
||||
const owners = sessionOwners.get(sessionId)
|
||||
return owners ? owners.has(uuid) : false
|
||||
}
|
||||
|
||||
export function storeGetSessionOwners(sessionId: string): Set<string> | undefined {
|
||||
return sessionOwners.get(sessionId);
|
||||
export function storeGetSessionOwners(
|
||||
sessionId: string,
|
||||
): Set<string> | undefined {
|
||||
return sessionOwners.get(sessionId)
|
||||
}
|
||||
|
||||
export function storeListSessionsByOwnerUuid(uuid: string): SessionRecord[] {
|
||||
const result: SessionRecord[] = [];
|
||||
const resultIds = new Set<string>();
|
||||
const result: SessionRecord[] = []
|
||||
const resultIds = new Set<string>()
|
||||
|
||||
// Collect sessions already owned by this UUID
|
||||
for (const [sessionId, owners] of sessionOwners) {
|
||||
if (owners.has(uuid)) {
|
||||
const session = sessions.get(sessionId);
|
||||
const session = sessions.get(sessionId)
|
||||
if (session) {
|
||||
result.push(session);
|
||||
resultIds.add(sessionId);
|
||||
result.push(session)
|
||||
resultIds.add(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-bind orphaned sessions (no owner — typically ACP agent sessions created via REST registration)
|
||||
for (const [sessionId, session] of sessions) {
|
||||
if (resultIds.has(sessionId)) continue;
|
||||
const owners = sessionOwners.get(sessionId);
|
||||
if (resultIds.has(sessionId)) continue
|
||||
const owners = sessionOwners.get(sessionId)
|
||||
// No owners map entry at all, or empty owners set
|
||||
const isOrphaned = !owners || owners.size === 0;
|
||||
const isOrphaned = !owners || owners.size === 0
|
||||
if (isOrphaned) {
|
||||
storeBindSession(sessionId, uuid);
|
||||
result.push(session);
|
||||
resultIds.add(sessionId);
|
||||
storeBindSession(sessionId, uuid)
|
||||
result.push(session)
|
||||
resultIds.add(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
// ---------- Work Items (cont.) ----------
|
||||
|
||||
export function storeCreateWorkItem(req: {
|
||||
environmentId: string;
|
||||
sessionId: string;
|
||||
secret: string;
|
||||
environmentId: string
|
||||
sessionId: string
|
||||
secret: string
|
||||
}): WorkItemRecord {
|
||||
const id = `work_${randomUUID().replace(/-/g, "")}`;
|
||||
const now = new Date();
|
||||
const id = `work_${randomUUID().replace(/-/g, '')}`
|
||||
const now = new Date()
|
||||
const record: WorkItemRecord = {
|
||||
id,
|
||||
environmentId: req.environmentId,
|
||||
sessionId: req.sessionId,
|
||||
state: "pending",
|
||||
state: 'pending',
|
||||
secret: req.secret,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
workItems.set(id, record);
|
||||
return record;
|
||||
}
|
||||
workItems.set(id, record)
|
||||
return record
|
||||
}
|
||||
|
||||
export function storeGetWorkItem(id: string): WorkItemRecord | undefined {
|
||||
return workItems.get(id);
|
||||
return workItems.get(id)
|
||||
}
|
||||
|
||||
export function storeGetPendingWorkItem(environmentId: string): WorkItemRecord | undefined {
|
||||
export function storeGetPendingWorkItem(
|
||||
environmentId: string,
|
||||
): WorkItemRecord | undefined {
|
||||
for (const item of workItems.values()) {
|
||||
if (item.environmentId === environmentId && item.state === "pending") {
|
||||
return item;
|
||||
if (item.environmentId === environmentId && item.state === 'pending') {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function storeUpdateWorkItem(id: string, patch: Partial<Pick<WorkItemRecord, "state" | "updatedAt">>): boolean {
|
||||
const rec = workItems.get(id);
|
||||
if (!rec) return false;
|
||||
Object.assign(rec, patch, { updatedAt: new Date() });
|
||||
return true;
|
||||
export function storeUpdateWorkItem(
|
||||
id: string,
|
||||
patch: Partial<Pick<WorkItemRecord, 'state' | 'updatedAt'>>,
|
||||
): boolean {
|
||||
const rec = workItems.get(id)
|
||||
if (!rec) return false
|
||||
Object.assign(rec, patch, { updatedAt: new Date() })
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- ACP Agent (reuses EnvironmentRecord with workerType="acp") ----------
|
||||
|
||||
/** List all ACP agents (environments with workerType="acp") */
|
||||
export function storeListAcpAgents(): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter((e) => e.workerType === "acp");
|
||||
return [...environments.values()].filter(e => e.workerType === 'acp')
|
||||
}
|
||||
|
||||
/** List ACP agents by channel group (stored in bridgeId field) */
|
||||
export function storeListAcpAgentsByChannelGroup(channelGroupId: string): EnvironmentRecord[] {
|
||||
export function storeListAcpAgentsByChannelGroup(
|
||||
channelGroupId: string,
|
||||
): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter(
|
||||
(e) => e.workerType === "acp" && e.bridgeId === channelGroupId,
|
||||
);
|
||||
e => e.workerType === 'acp' && e.bridgeId === channelGroupId,
|
||||
)
|
||||
}
|
||||
|
||||
/** List online ACP agents */
|
||||
export function storeListOnlineAcpAgents(): EnvironmentRecord[] {
|
||||
return [...environments.values()].filter(
|
||||
(e) => e.workerType === "acp" && e.status === "active",
|
||||
);
|
||||
e => e.workerType === 'acp' && e.status === 'active',
|
||||
)
|
||||
}
|
||||
|
||||
/** Mark an ACP agent as offline */
|
||||
export function storeMarkAcpAgentOffline(id: string): boolean {
|
||||
const rec = environments.get(id);
|
||||
if (!rec || rec.workerType !== "acp") return false;
|
||||
Object.assign(rec, { status: "offline", updatedAt: new Date() });
|
||||
return true;
|
||||
const rec = environments.get(id)
|
||||
if (!rec || rec.workerType !== 'acp') return false
|
||||
Object.assign(rec, { status: 'offline', updatedAt: new Date() })
|
||||
return true
|
||||
}
|
||||
|
||||
/** Mark an ACP agent as online (on reconnect) */
|
||||
export function storeMarkAcpAgentOnline(id: string): boolean {
|
||||
const rec = environments.get(id);
|
||||
if (!rec || rec.workerType !== "acp") return false;
|
||||
Object.assign(rec, { status: "active", lastPollAt: new Date(), updatedAt: new Date() });
|
||||
return true;
|
||||
const rec = environments.get(id)
|
||||
if (!rec || rec.workerType !== 'acp') return false
|
||||
Object.assign(rec, {
|
||||
status: 'active',
|
||||
lastPollAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- Reset (for tests) ----------
|
||||
|
||||
export function storeReset() {
|
||||
users.clear();
|
||||
tokenToUser.clear();
|
||||
environments.clear();
|
||||
sessions.clear();
|
||||
workItems.clear();
|
||||
sessionWorkers.clear();
|
||||
sessionOwners.clear();
|
||||
users.clear()
|
||||
tokenToUser.clear()
|
||||
environments.clear()
|
||||
sessions.clear()
|
||||
workItems.clear()
|
||||
sessionWorkers.clear()
|
||||
sessionOwners.clear()
|
||||
}
|
||||
|
||||
@@ -1,72 +1,75 @@
|
||||
import type { WSContext } from "hono/ws";
|
||||
import {
|
||||
findAcpConnectionByAgentId,
|
||||
sendToAgentWs,
|
||||
} from "./acp-ws-handler";
|
||||
import { getAcpEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { log, error as logError } from "../logger";
|
||||
import type { WSContext } from 'hono/ws'
|
||||
import { findAcpConnectionByAgentId, sendToAgentWs } from './acp-ws-handler'
|
||||
import { getAcpEventBus } from './event-bus'
|
||||
import type { SessionEvent } from './event-bus'
|
||||
import { log, error as logError } from '../logger'
|
||||
|
||||
// Per-relay connection state
|
||||
interface RelayConnectionEntry {
|
||||
agentId: string;
|
||||
unsub: (() => void) | null;
|
||||
keepalive: ReturnType<typeof setInterval> | null;
|
||||
ws: WSContext;
|
||||
openTime: number;
|
||||
agentId: string
|
||||
unsub: (() => void) | null
|
||||
keepalive: ReturnType<typeof setInterval> | null
|
||||
ws: WSContext
|
||||
openTime: number
|
||||
}
|
||||
|
||||
const relayConnections = new Map<string, RelayConnectionEntry>(); // key: relayWsId
|
||||
const relayConnections = new Map<string, RelayConnectionEntry>() // key: relayWsId
|
||||
|
||||
const RELAY_KEEPALIVE_INTERVAL_MS = 20_000;
|
||||
const RELAY_KEEPALIVE_INTERVAL_MS = 20_000
|
||||
|
||||
/** Send a JSON message to relay WS */
|
||||
function sendToRelayWs(ws: WSContext, msg: object): void {
|
||||
if (ws.readyState !== 1) return;
|
||||
if (ws.readyState !== 1) return
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
ws.send(JSON.stringify(msg))
|
||||
} catch (err) {
|
||||
logError("[ACP-Relay] send error:", err);
|
||||
logError('[ACP-Relay] send error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onOpen — finds target agent and bridges connection */
|
||||
export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: string): void {
|
||||
log(`[ACP-Relay] Relay connection opened: relayWsId=${relayWsId} agentId=${agentId}`);
|
||||
export function handleRelayOpen(
|
||||
ws: WSContext,
|
||||
relayWsId: string,
|
||||
agentId: string,
|
||||
): void {
|
||||
log(
|
||||
`[ACP-Relay] Relay connection opened: relayWsId=${relayWsId} agentId=${agentId}`,
|
||||
)
|
||||
|
||||
// Check if agent is online
|
||||
const agentConn = findAcpConnectionByAgentId(agentId);
|
||||
const agentConn = findAcpConnectionByAgentId(agentId)
|
||||
if (!agentConn) {
|
||||
log(`[ACP-Relay] Agent ${agentId} not found or offline`);
|
||||
sendToRelayWs(ws, { type: "error", message: "Agent not found or offline" });
|
||||
ws.close(4004, "agent not found");
|
||||
return;
|
||||
log(`[ACP-Relay] Agent ${agentId} not found or offline`)
|
||||
sendToRelayWs(ws, { type: 'error', message: 'Agent not found or offline' })
|
||||
ws.close(4004, 'agent not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Keepalive interval
|
||||
const keepalive = setInterval(() => {
|
||||
const entry = relayConnections.get(relayWsId);
|
||||
const entry = relayConnections.get(relayWsId)
|
||||
if (!entry || entry.ws.readyState !== 1) {
|
||||
clearInterval(keepalive);
|
||||
return;
|
||||
clearInterval(keepalive)
|
||||
return
|
||||
}
|
||||
sendToRelayWs(entry.ws, { type: "keep_alive" });
|
||||
}, RELAY_KEEPALIVE_INTERVAL_MS);
|
||||
sendToRelayWs(entry.ws, { type: 'keep_alive' })
|
||||
}, RELAY_KEEPALIVE_INTERVAL_MS)
|
||||
|
||||
// Subscribe to channel group EventBus — forward agent responses to frontend
|
||||
const channelGroupId = agentConn.channelGroupId;
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
const channelGroupId = agentConn.channelGroupId
|
||||
const bus = getAcpEventBus(channelGroupId)
|
||||
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
if (event.direction !== "inbound") return;
|
||||
if (ws.readyState !== 1) return
|
||||
if (event.direction !== 'inbound') return
|
||||
// Handle agent disconnect specially: send status to frontend
|
||||
if (event.type === "agent_disconnect") {
|
||||
sendToRelayWs(ws, { type: "status", payload: { connected: false } });
|
||||
return;
|
||||
if (event.type === 'agent_disconnect') {
|
||||
sendToRelayWs(ws, { type: 'status', payload: { connected: false } })
|
||||
return
|
||||
}
|
||||
// Forward agent responses to the frontend WebSocket
|
||||
sendToRelayWs(ws, event.payload as object);
|
||||
});
|
||||
sendToRelayWs(ws, event.payload as object)
|
||||
})
|
||||
|
||||
relayConnections.set(relayWsId, {
|
||||
agentId,
|
||||
@@ -74,7 +77,7 @@ export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: strin
|
||||
keepalive,
|
||||
ws,
|
||||
openTime: Date.now(),
|
||||
});
|
||||
})
|
||||
|
||||
// Don't send a synthetic status message here!
|
||||
// The frontend sends a "connect" command, which acp-link processes
|
||||
@@ -82,70 +85,83 @@ export function handleRelayOpen(ws: WSContext, relayWsId: string, agentId: strin
|
||||
// Sending a fake status would make the frontend think it's connected
|
||||
// before the agent process is actually ready.
|
||||
|
||||
log(`[ACP-Relay] Relay established: relayWsId=${relayWsId} → agentId=${agentId}`);
|
||||
log(
|
||||
`[ACP-Relay] Relay established: relayWsId=${relayWsId} → agentId=${agentId}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** Called from onMessage — forwards frontend messages to acp-link */
|
||||
export function handleRelayMessage(ws: WSContext, relayWsId: string, data: string): void {
|
||||
const entry = relayConnections.get(relayWsId);
|
||||
if (!entry) return;
|
||||
export function handleRelayMessage(
|
||||
ws: WSContext,
|
||||
relayWsId: string,
|
||||
data: string,
|
||||
): void {
|
||||
const entry = relayConnections.get(relayWsId)
|
||||
if (!entry) return
|
||||
|
||||
const lines = data.split("\n").filter((l) => l.trim());
|
||||
const lines = data.split('\n').filter(l => l.trim())
|
||||
for (const line of lines) {
|
||||
let msg: Record<string, unknown>;
|
||||
let msg: Record<string, unknown>
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
logError("[ACP-Relay] parse error:", line);
|
||||
continue;
|
||||
logError('[ACP-Relay] parse error:', line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ignore keepalive responses
|
||||
if (msg.type === "keep_alive") continue;
|
||||
if (msg.type === 'keep_alive') continue
|
||||
|
||||
// Forward to acp-link agent
|
||||
const sent = sendToAgentWs(entry.agentId, msg);
|
||||
const sent = sendToAgentWs(entry.agentId, msg)
|
||||
if (!sent) {
|
||||
sendToRelayWs(ws, { type: "error", message: "Agent connection lost" });
|
||||
return;
|
||||
sendToRelayWs(ws, { type: 'error', message: 'Agent connection lost' })
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onClose — cleans up relay connection */
|
||||
export function handleRelayClose(ws: WSContext, relayWsId: string, code?: number, reason?: string): void {
|
||||
const entry = relayConnections.get(relayWsId);
|
||||
if (!entry) return;
|
||||
export function handleRelayClose(
|
||||
ws: WSContext,
|
||||
relayWsId: string,
|
||||
code?: number,
|
||||
reason?: string,
|
||||
): void {
|
||||
const entry = relayConnections.get(relayWsId)
|
||||
if (!entry) return
|
||||
|
||||
const duration = Math.round((Date.now() - entry.openTime) / 1000);
|
||||
log(`[ACP-Relay] Connection closed: relayWsId=${relayWsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
const duration = Math.round((Date.now() - entry.openTime) / 1000)
|
||||
log(
|
||||
`[ACP-Relay] Connection closed: relayWsId=${relayWsId} agentId=${entry.agentId} code=${code ?? 'none'} reason=${reason || '(none)'} duration=${duration}s`,
|
||||
)
|
||||
|
||||
if (entry.unsub) {
|
||||
entry.unsub();
|
||||
entry.unsub()
|
||||
}
|
||||
if (entry.keepalive) {
|
||||
clearInterval(entry.keepalive);
|
||||
clearInterval(entry.keepalive)
|
||||
}
|
||||
|
||||
relayConnections.delete(relayWsId);
|
||||
relayConnections.delete(relayWsId)
|
||||
}
|
||||
|
||||
/** Close all relay connections (for graceful shutdown) */
|
||||
export function closeAllRelayConnections(): void {
|
||||
if (relayConnections.size === 0) return;
|
||||
if (relayConnections.size === 0) return
|
||||
|
||||
log(`[ACP-Relay] Closing ${relayConnections.size} relay connection(s)...`);
|
||||
log(`[ACP-Relay] Closing ${relayConnections.size} relay connection(s)...`)
|
||||
for (const [relayWsId, entry] of relayConnections) {
|
||||
try {
|
||||
if (entry.unsub) entry.unsub();
|
||||
if (entry.keepalive) clearInterval(entry.keepalive);
|
||||
if (entry.unsub) entry.unsub()
|
||||
if (entry.keepalive) clearInterval(entry.keepalive)
|
||||
if (entry.ws.readyState === 1) {
|
||||
entry.ws.close(1001, "server_shutdown");
|
||||
entry.ws.close(1001, 'server_shutdown')
|
||||
}
|
||||
} catch {
|
||||
// ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
relayConnections.clear();
|
||||
log("[ACP-Relay] All relay connections closed");
|
||||
relayConnections.clear()
|
||||
log('[ACP-Relay] All relay connections closed')
|
||||
}
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { log } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getAcpEventBus } from "./event-bus";
|
||||
import { log } from '../logger'
|
||||
import type { Context } from 'hono'
|
||||
import type { SessionEvent } from './event-bus'
|
||||
import { getAcpEventBus } from './event-bus'
|
||||
|
||||
/** Create SSE response stream for an ACP channel group */
|
||||
export function createAcpSSEStream(c: Context, channelGroupId: string, fromSeqNum = 0) {
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
export function createAcpSSEStream(
|
||||
c: Context,
|
||||
channelGroupId: string,
|
||||
fromSeqNum = 0,
|
||||
) {
|
||||
const bus = getAcpEventBus(channelGroupId)
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
// Send historical events if reconnecting
|
||||
if (fromSeqNum > 0) {
|
||||
const missed = bus.getEventsSince(fromSeqNum);
|
||||
const missed = bus.getEventsSince(fromSeqNum)
|
||||
for (const event of missed) {
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
@@ -21,60 +25,70 @@ export function createAcpSSEStream(c: Context, channelGroupId: string, fromSeqNu
|
||||
direction: event.direction,
|
||||
seqNum: event.seqNum,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||
})
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial keepalive
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
controller.enqueue(encoder.encode(': keepalive\n\n'))
|
||||
|
||||
// Subscribe to new events
|
||||
const unsub = bus.subscribe((event) => {
|
||||
const unsub = bus.subscribe(event => {
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
direction: event.direction,
|
||||
seqNum: event.seqNum,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
})
|
||||
try {
|
||||
log(`[ACP-SSE] -> subscriber: channelGroup=${channelGroupId} type=${event.type} seq=${event.seqNum}`);
|
||||
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||
log(
|
||||
`[ACP-SSE] -> subscriber: channelGroup=${channelGroupId} type=${event.type} seq=${event.seqNum}`,
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
|
||||
),
|
||||
)
|
||||
} catch {
|
||||
unsub();
|
||||
unsub()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Keepalive interval
|
||||
const keepalive = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
controller.enqueue(encoder.encode(': keepalive\n\n'))
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
unsub();
|
||||
clearInterval(keepalive)
|
||||
unsub()
|
||||
}
|
||||
}, 15000);
|
||||
}, 15000)
|
||||
|
||||
// Cleanup on abort
|
||||
c.req.raw.signal.addEventListener("abort", () => {
|
||||
unsub();
|
||||
clearInterval(keepalive);
|
||||
c.req.raw.signal.addEventListener('abort', () => {
|
||||
unsub()
|
||||
clearInterval(keepalive)
|
||||
try {
|
||||
controller.close();
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,313 +1,343 @@
|
||||
import type { WSContext } from "hono/ws";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getAcpEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import type { WSContext } from 'hono/ws'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { getAcpEventBus } from './event-bus'
|
||||
import type { SessionEvent } from './event-bus'
|
||||
import {
|
||||
storeCreateEnvironment,
|
||||
storeGetEnvironment,
|
||||
storeMarkAcpAgentOffline,
|
||||
storeMarkAcpAgentOnline,
|
||||
storeUpdateEnvironment,
|
||||
} from "../store";
|
||||
import { config } from "../config";
|
||||
import { log, error as logError } from "../logger";
|
||||
} from '../store'
|
||||
import { config } from '../config'
|
||||
import { log, error as logError } from '../logger'
|
||||
|
||||
// Per-connection state
|
||||
interface AcpConnectionEntry {
|
||||
agentId: string | null; // Set after register message
|
||||
channelGroupId: string;
|
||||
unsub: (() => void) | null;
|
||||
keepalive: ReturnType<typeof setInterval> | null;
|
||||
ws: WSContext;
|
||||
openTime: number;
|
||||
lastClientActivity: number;
|
||||
capabilities: Record<string, unknown> | null;
|
||||
agentId: string | null // Set after register message
|
||||
channelGroupId: string
|
||||
unsub: (() => void) | null
|
||||
keepalive: ReturnType<typeof setInterval> | null
|
||||
ws: WSContext
|
||||
openTime: number
|
||||
lastClientActivity: number
|
||||
capabilities: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const connections = new Map<string, AcpConnectionEntry>(); // key: wsId
|
||||
const connections = new Map<string, AcpConnectionEntry>() // key: wsId
|
||||
|
||||
const SERVER_KEEPALIVE_INTERVAL_MS = config.wsKeepaliveInterval * 1000;
|
||||
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
|
||||
const SERVER_KEEPALIVE_INTERVAL_MS = config.wsKeepaliveInterval * 1000
|
||||
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3
|
||||
|
||||
/** Send a JSON message to a WS connection (NDJSON format) */
|
||||
function sendToWs(ws: WSContext, msg: object): void {
|
||||
if (ws.readyState !== 1) return;
|
||||
if (ws.readyState !== 1) return
|
||||
try {
|
||||
ws.send(JSON.stringify(msg) + "\n");
|
||||
ws.send(JSON.stringify(msg) + '\n')
|
||||
} catch (err) {
|
||||
logError("[ACP-WS] send error:", err);
|
||||
logError('[ACP-WS] send error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onOpen — initializes connection tracking */
|
||||
export function handleAcpWsOpen(ws: WSContext, wsId: string): void {
|
||||
log(`[ACP-WS] Connection opened: wsId=${wsId}`);
|
||||
log(`[ACP-WS] Connection opened: wsId=${wsId}`)
|
||||
|
||||
const keepalive = setInterval(() => {
|
||||
const entry = connections.get(wsId);
|
||||
const entry = connections.get(wsId)
|
||||
if (!entry || entry.ws.readyState !== 1) {
|
||||
clearInterval(keepalive);
|
||||
return;
|
||||
clearInterval(keepalive)
|
||||
return
|
||||
}
|
||||
const silenceMs = Date.now() - entry.lastClientActivity;
|
||||
const silenceMs = Date.now() - entry.lastClientActivity
|
||||
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
|
||||
log(`[ACP-WS] Client inactive for ${Math.round(silenceMs / 1000)}s, closing dead connection`);
|
||||
log(
|
||||
`[ACP-WS] Client inactive for ${Math.round(silenceMs / 1000)}s, closing dead connection`,
|
||||
)
|
||||
try {
|
||||
entry.ws.close(1000, "client inactive");
|
||||
entry.ws.close(1000, 'client inactive')
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
clearInterval(keepalive)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
sendToWs(entry.ws, { type: "keep_alive" });
|
||||
}, SERVER_KEEPALIVE_INTERVAL_MS);
|
||||
sendToWs(entry.ws, { type: 'keep_alive' })
|
||||
}, SERVER_KEEPALIVE_INTERVAL_MS)
|
||||
|
||||
connections.set(wsId, {
|
||||
agentId: null,
|
||||
channelGroupId: "",
|
||||
channelGroupId: '',
|
||||
unsub: null,
|
||||
keepalive,
|
||||
ws,
|
||||
openTime: Date.now(),
|
||||
lastClientActivity: Date.now(),
|
||||
capabilities: null,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** Handle register message — legacy WS-only registration (still supported) */
|
||||
function handleRegister(wsId: string, msg: Record<string, unknown>): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
const entry = connections.get(wsId)
|
||||
if (!entry) return
|
||||
|
||||
if (entry.agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Already registered" });
|
||||
return;
|
||||
sendToWs(entry.ws, { type: 'error', message: 'Already registered' })
|
||||
return
|
||||
}
|
||||
|
||||
const agentName = (msg.agent_name as string) || "unknown";
|
||||
const capabilities = msg.capabilities as Record<string, unknown> | undefined;
|
||||
const channelGroupId = (msg.channel_group_id as string) || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const acpLinkVersion = (msg.acp_link_version as string) || null;
|
||||
const maxSessions = typeof msg.max_sessions === "number" ? msg.max_sessions : 1;
|
||||
const agentName = (msg.agent_name as string) || 'unknown'
|
||||
const capabilities = msg.capabilities as Record<string, unknown> | undefined
|
||||
const channelGroupId =
|
||||
(msg.channel_group_id as string) ||
|
||||
`group_${randomUUID().replace(/-/g, '').slice(0, 12)}`
|
||||
const acpLinkVersion = (msg.acp_link_version as string) || null
|
||||
const maxSessions =
|
||||
typeof msg.max_sessions === 'number' ? msg.max_sessions : 1
|
||||
|
||||
// Create EnvironmentRecord with workerType="acp"
|
||||
const secret = config.apiKeys[0] || "";
|
||||
const secret = config.apiKeys[0] || ''
|
||||
const record = storeCreateEnvironment({
|
||||
secret,
|
||||
machineName: agentName,
|
||||
workerType: "acp",
|
||||
workerType: 'acp',
|
||||
bridgeId: channelGroupId,
|
||||
maxSessions,
|
||||
capabilities: capabilities || undefined,
|
||||
} as Parameters<typeof storeCreateEnvironment>[0]);
|
||||
} as Parameters<typeof storeCreateEnvironment>[0])
|
||||
|
||||
// Store ACP-specific metadata via environment update
|
||||
storeUpdateEnvironment(record.id, {
|
||||
status: "active",
|
||||
} as Parameters<typeof storeUpdateEnvironment>[1]);
|
||||
status: 'active',
|
||||
} as Parameters<typeof storeUpdateEnvironment>[1])
|
||||
|
||||
entry.agentId = record.id;
|
||||
entry.channelGroupId = channelGroupId;
|
||||
entry.capabilities = capabilities || null;
|
||||
entry.agentId = record.id
|
||||
entry.channelGroupId = channelGroupId
|
||||
entry.capabilities = capabilities || null
|
||||
|
||||
// Subscribe to channel group EventBus — broadcast events to this WS
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
const bus = getAcpEventBus(channelGroupId)
|
||||
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||
if (entry.ws.readyState !== 1) return;
|
||||
if (event.direction !== "outbound") return;
|
||||
if (entry.ws.readyState !== 1) return
|
||||
if (event.direction !== 'outbound') return
|
||||
// Forward outbound events as raw ACP messages
|
||||
sendToWs(entry.ws, event.payload as object);
|
||||
});
|
||||
entry.unsub = unsub;
|
||||
sendToWs(entry.ws, event.payload as object)
|
||||
})
|
||||
entry.unsub = unsub
|
||||
|
||||
log(`[ACP-WS] Agent registered (legacy WS): agentId=${record.id} channelGroup=${channelGroupId} name=${agentName}`);
|
||||
log(
|
||||
`[ACP-WS] Agent registered (legacy WS): agentId=${record.id} channelGroup=${channelGroupId} name=${agentName}`,
|
||||
)
|
||||
sendToWs(entry.ws, {
|
||||
type: "registered",
|
||||
type: 'registered',
|
||||
agent_id: record.id,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** Handle identify message — binds WS to an existing agent registered via REST */
|
||||
function handleIdentify(wsId: string, msg: Record<string, unknown>): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
const entry = connections.get(wsId)
|
||||
if (!entry) return
|
||||
|
||||
if (entry.agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Already identified" });
|
||||
return;
|
||||
sendToWs(entry.ws, { type: 'error', message: 'Already identified' })
|
||||
return
|
||||
}
|
||||
|
||||
const agentId = msg.agent_id as string;
|
||||
const agentId = msg.agent_id as string
|
||||
if (!agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Missing agent_id" });
|
||||
return;
|
||||
sendToWs(entry.ws, { type: 'error', message: 'Missing agent_id' })
|
||||
return
|
||||
}
|
||||
|
||||
// Look up the environment record (created via REST registration)
|
||||
const record = storeGetEnvironment(agentId);
|
||||
if (!record || record.workerType !== "acp") {
|
||||
sendToWs(entry.ws, { type: "error", message: "Agent not found" });
|
||||
return;
|
||||
const record = storeGetEnvironment(agentId)
|
||||
if (!record || record.workerType !== 'acp') {
|
||||
sendToWs(entry.ws, { type: 'error', message: 'Agent not found' })
|
||||
return
|
||||
}
|
||||
|
||||
// Update status to active
|
||||
storeMarkAcpAgentOnline(agentId);
|
||||
storeMarkAcpAgentOnline(agentId)
|
||||
|
||||
const channelGroupId = record.bridgeId || `group_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
||||
const channelGroupId =
|
||||
record.bridgeId || `group_${randomUUID().replace(/-/g, '').slice(0, 12)}`
|
||||
|
||||
entry.agentId = record.id;
|
||||
entry.channelGroupId = channelGroupId;
|
||||
entry.capabilities = record.capabilities || null;
|
||||
entry.agentId = record.id
|
||||
entry.channelGroupId = channelGroupId
|
||||
entry.capabilities = record.capabilities || null
|
||||
|
||||
// Subscribe to channel group EventBus — broadcast events to this WS
|
||||
const bus = getAcpEventBus(channelGroupId);
|
||||
const bus = getAcpEventBus(channelGroupId)
|
||||
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||
if (entry.ws.readyState !== 1) return;
|
||||
if (event.direction !== "outbound") return;
|
||||
sendToWs(entry.ws, event.payload as object);
|
||||
});
|
||||
entry.unsub = unsub;
|
||||
if (entry.ws.readyState !== 1) return
|
||||
if (event.direction !== 'outbound') return
|
||||
sendToWs(entry.ws, event.payload as object)
|
||||
})
|
||||
entry.unsub = unsub
|
||||
|
||||
log(`[ACP-WS] Agent identified (REST+WS): agentId=${record.id} channelGroup=${channelGroupId}`);
|
||||
log(
|
||||
`[ACP-WS] Agent identified (REST+WS): agentId=${record.id} channelGroup=${channelGroupId}`,
|
||||
)
|
||||
sendToWs(entry.ws, {
|
||||
type: "identified",
|
||||
type: 'identified',
|
||||
agent_id: record.id,
|
||||
channel_group_id: channelGroupId,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
/** Called from onMessage — processes NDJSON lines */
|
||||
export function handleAcpWsMessage(ws: WSContext, wsId: string, data: string): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
export function handleAcpWsMessage(
|
||||
ws: WSContext,
|
||||
wsId: string,
|
||||
data: string,
|
||||
): void {
|
||||
const entry = connections.get(wsId)
|
||||
if (!entry) return
|
||||
|
||||
entry.lastClientActivity = Date.now();
|
||||
entry.lastClientActivity = Date.now()
|
||||
|
||||
const lines = data.split("\n").filter((l) => l.trim());
|
||||
const lines = data.split('\n').filter(l => l.trim())
|
||||
for (const line of lines) {
|
||||
let msg: Record<string, unknown>;
|
||||
let msg: Record<string, unknown>
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
msg = JSON.parse(line)
|
||||
} catch {
|
||||
logError("[ACP-WS] parse error:", line);
|
||||
continue;
|
||||
logError('[ACP-WS] parse error:', line)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle keepalive
|
||||
if (msg.type === "keep_alive") {
|
||||
if (msg.type === 'keep_alive') {
|
||||
// Update last activity timestamp (only if registered)
|
||||
if (entry.agentId) {
|
||||
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
|
||||
storeUpdateEnvironment(entry.agentId, {
|
||||
lastPollAt: new Date(),
|
||||
} as Parameters<typeof storeUpdateEnvironment>[1])
|
||||
}
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle registration (legacy WS-only)
|
||||
if (msg.type === "register") {
|
||||
handleRegister(wsId, msg);
|
||||
continue;
|
||||
if (msg.type === 'register') {
|
||||
handleRegister(wsId, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle identify (REST registration + WS binding)
|
||||
if (msg.type === "identify") {
|
||||
handleIdentify(wsId, msg);
|
||||
continue;
|
||||
if (msg.type === 'identify') {
|
||||
handleIdentify(wsId, msg)
|
||||
continue
|
||||
}
|
||||
|
||||
// Not registered yet — reject
|
||||
if (!entry.agentId) {
|
||||
sendToWs(entry.ws, { type: "error", message: "Not registered. Send register message first." });
|
||||
continue;
|
||||
sendToWs(entry.ws, {
|
||||
type: 'error',
|
||||
message: 'Not registered. Send register message first.',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Update agent activity
|
||||
storeUpdateEnvironment(entry.agentId, { lastPollAt: new Date() } as Parameters<typeof storeUpdateEnvironment>[1]);
|
||||
storeUpdateEnvironment(entry.agentId, {
|
||||
lastPollAt: new Date(),
|
||||
} as Parameters<typeof storeUpdateEnvironment>[1])
|
||||
|
||||
// Pass-through: publish to channel group EventBus as inbound
|
||||
const bus = getAcpEventBus(entry.channelGroupId);
|
||||
const bus = getAcpEventBus(entry.channelGroupId)
|
||||
bus.publish({
|
||||
id: randomUUID(),
|
||||
sessionId: entry.channelGroupId,
|
||||
type: (msg.type as string) || "acp_message",
|
||||
type: (msg.type as string) || 'acp_message',
|
||||
payload: msg,
|
||||
direction: "inbound",
|
||||
});
|
||||
direction: 'inbound',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onClose — marks agent offline and cleans up */
|
||||
export function handleAcpWsClose(ws: WSContext, wsId: string, code?: number, reason?: string): void {
|
||||
const entry = connections.get(wsId);
|
||||
if (!entry) return;
|
||||
export function handleAcpWsClose(
|
||||
ws: WSContext,
|
||||
wsId: string,
|
||||
code?: number,
|
||||
reason?: string,
|
||||
): void {
|
||||
const entry = connections.get(wsId)
|
||||
if (!entry) return
|
||||
|
||||
const duration = Math.round((Date.now() - entry.openTime) / 1000);
|
||||
log(`[ACP-WS] Connection closed: wsId=${wsId} agentId=${entry.agentId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
const duration = Math.round((Date.now() - entry.openTime) / 1000)
|
||||
log(
|
||||
`[ACP-WS] Connection closed: wsId=${wsId} agentId=${entry.agentId} code=${code ?? 'none'} reason=${reason || '(none)'} duration=${duration}s`,
|
||||
)
|
||||
|
||||
if (entry.unsub) {
|
||||
entry.unsub();
|
||||
entry.unsub()
|
||||
}
|
||||
if (entry.keepalive) {
|
||||
clearInterval(entry.keepalive);
|
||||
clearInterval(entry.keepalive)
|
||||
}
|
||||
|
||||
// Mark agent as offline (don't delete record — allow reconnect)
|
||||
if (entry.agentId) {
|
||||
storeMarkAcpAgentOffline(entry.agentId);
|
||||
storeMarkAcpAgentOffline(entry.agentId)
|
||||
|
||||
// Notify all relay connections that this agent is gone
|
||||
if (entry.channelGroupId) {
|
||||
const bus = getAcpEventBus(entry.channelGroupId);
|
||||
const bus = getAcpEventBus(entry.channelGroupId)
|
||||
bus.publish({
|
||||
id: randomUUID(),
|
||||
sessionId: entry.channelGroupId,
|
||||
type: "agent_disconnect",
|
||||
type: 'agent_disconnect',
|
||||
payload: { agentId: entry.agentId },
|
||||
direction: "inbound",
|
||||
});
|
||||
direction: 'inbound',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
connections.delete(wsId);
|
||||
connections.delete(wsId)
|
||||
}
|
||||
|
||||
/** Find an active ACP connection by agent ID */
|
||||
export function findAcpConnectionByAgentId(agentId: string): AcpConnectionEntry | null {
|
||||
export function findAcpConnectionByAgentId(
|
||||
agentId: string,
|
||||
): AcpConnectionEntry | null {
|
||||
for (const entry of connections.values()) {
|
||||
if (entry.agentId === agentId && entry.ws.readyState === 1) {
|
||||
return entry;
|
||||
return entry
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
/** Send a JSON message directly to an agent's WebSocket connection */
|
||||
export function sendToAgentWs(agentId: string, msg: object): boolean {
|
||||
const entry = findAcpConnectionByAgentId(agentId);
|
||||
if (!entry) return false;
|
||||
sendToWs(entry.ws, msg);
|
||||
return true;
|
||||
const entry = findAcpConnectionByAgentId(agentId)
|
||||
if (!entry) return false
|
||||
sendToWs(entry.ws, msg)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Gracefully close all ACP WebSocket connections */
|
||||
export function closeAllAcpConnections(): void {
|
||||
if (connections.size === 0) return;
|
||||
if (connections.size === 0) return
|
||||
|
||||
log(`[ACP-WS] Gracefully closing ${connections.size} ACP connection(s)...`);
|
||||
log(`[ACP-WS] Gracefully closing ${connections.size} ACP connection(s)...`)
|
||||
for (const [wsId, entry] of connections) {
|
||||
try {
|
||||
if (entry.unsub) entry.unsub();
|
||||
if (entry.keepalive) clearInterval(entry.keepalive);
|
||||
if (entry.unsub) entry.unsub()
|
||||
if (entry.keepalive) clearInterval(entry.keepalive)
|
||||
if (entry.ws.readyState === 1) {
|
||||
entry.ws.close(1001, "server_shutdown");
|
||||
entry.ws.close(1001, 'server_shutdown')
|
||||
}
|
||||
if (entry.agentId) {
|
||||
storeMarkAcpAgentOffline(entry.agentId);
|
||||
storeMarkAcpAgentOffline(entry.agentId)
|
||||
}
|
||||
} catch {
|
||||
// ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
connections.clear();
|
||||
log("[ACP-WS] All connections closed");
|
||||
connections.clear()
|
||||
log('[ACP-WS] All connections closed')
|
||||
}
|
||||
|
||||
@@ -1,74 +1,83 @@
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import type { SessionEvent } from './event-bus'
|
||||
|
||||
/**
|
||||
* Convert an internal session event into the SDK/control message shape that
|
||||
* bridge workers consume on both the legacy WS path and the v2 worker SSE path.
|
||||
*/
|
||||
export function toClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
const payload = event.payload as Record<string, unknown> | null;
|
||||
const payload = event.payload as Record<string, unknown> | null
|
||||
const messageUuid =
|
||||
typeof payload?.uuid === "string" && payload.uuid ? payload.uuid : event.id;
|
||||
typeof payload?.uuid === 'string' && payload.uuid ? payload.uuid : event.id
|
||||
|
||||
if (event.type === "user" || event.type === "user_message") {
|
||||
if (event.type === 'user' || event.type === 'user_message') {
|
||||
return {
|
||||
type: "user",
|
||||
type: 'user',
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
...(payload?.isSynthetic === true ? { isSynthetic: true } : {}),
|
||||
message: {
|
||||
role: "user",
|
||||
content: payload?.content ?? payload?.message ?? "",
|
||||
role: 'user',
|
||||
content: payload?.content ?? payload?.message ?? '',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "permission_response" || event.type === "control_response") {
|
||||
const approved = !!payload?.approved;
|
||||
const existingResponse = payload?.response as Record<string, unknown> | undefined;
|
||||
if (
|
||||
event.type === 'permission_response' ||
|
||||
event.type === 'control_response'
|
||||
) {
|
||||
const approved = !!payload?.approved
|
||||
const existingResponse = payload?.response as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (existingResponse) {
|
||||
return { type: "control_response", response: existingResponse };
|
||||
return { type: 'control_response', response: existingResponse }
|
||||
}
|
||||
|
||||
const updatedInput = payload?.updated_input as Record<string, unknown> | undefined;
|
||||
const updatedPermissions = payload?.updated_permissions as Record<string, unknown>[] | undefined;
|
||||
const feedbackMessage = payload?.message as string | undefined;
|
||||
const updatedInput = payload?.updated_input as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
const updatedPermissions = payload?.updated_permissions as
|
||||
| Record<string, unknown>[]
|
||||
| undefined
|
||||
const feedbackMessage = payload?.message as string | undefined
|
||||
|
||||
return {
|
||||
type: "control_response",
|
||||
type: 'control_response',
|
||||
response: {
|
||||
subtype: approved ? "success" : "error",
|
||||
request_id: payload?.request_id ?? "",
|
||||
subtype: approved ? 'success' : 'error',
|
||||
request_id: payload?.request_id ?? '',
|
||||
...(approved
|
||||
? {
|
||||
response: {
|
||||
behavior: "allow" as const,
|
||||
behavior: 'allow' as const,
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
...(updatedPermissions ? { updatedPermissions } : {}),
|
||||
},
|
||||
}
|
||||
: {
|
||||
error: "Permission denied by user",
|
||||
response: { behavior: "deny" as const },
|
||||
error: 'Permission denied by user',
|
||||
response: { behavior: 'deny' as const },
|
||||
...(feedbackMessage ? { message: feedbackMessage } : {}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "interrupt") {
|
||||
if (event.type === 'interrupt') {
|
||||
return {
|
||||
type: "control_request",
|
||||
type: 'control_request',
|
||||
request_id: event.id,
|
||||
request: { subtype: "interrupt" },
|
||||
};
|
||||
request: { subtype: 'interrupt' },
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "control_request") {
|
||||
if (event.type === 'control_request') {
|
||||
return {
|
||||
type: "control_request",
|
||||
type: 'control_request',
|
||||
request_id: payload?.request_id ?? event.id,
|
||||
request: payload?.request ?? payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -76,5 +85,5 @@ export function toClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
uuid: messageUuid,
|
||||
session_id: event.sessionId,
|
||||
message: payload,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,116 +1,116 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import { log, error as logError } from '../logger'
|
||||
|
||||
export interface SessionEvent {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
direction: "inbound" | "outbound";
|
||||
seqNum: number;
|
||||
createdAt: number;
|
||||
id: string
|
||||
sessionId: string
|
||||
type: string
|
||||
payload: unknown
|
||||
direction: 'inbound' | 'outbound'
|
||||
seqNum: number
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
type Subscriber = (event: SessionEvent) => void;
|
||||
type Subscriber = (event: SessionEvent) => void
|
||||
|
||||
const MAX_EVENTS_PER_BUS = 5000;
|
||||
const MAX_EVENTS_PER_BUS = 5000
|
||||
|
||||
export class EventBus {
|
||||
private subscribers = new Set<Subscriber>();
|
||||
private events: SessionEvent[] = [];
|
||||
private seqNum = 0;
|
||||
private closed = false;
|
||||
private subscribers = new Set<Subscriber>()
|
||||
private events: SessionEvent[] = []
|
||||
private seqNum = 0
|
||||
private closed = false
|
||||
|
||||
subscribe(callback: Subscriber): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => this.subscribers.delete(callback);
|
||||
this.subscribers.add(callback)
|
||||
return () => this.subscribers.delete(callback)
|
||||
}
|
||||
|
||||
subscriberCount(): number {
|
||||
return this.subscribers.size;
|
||||
return this.subscribers.size
|
||||
}
|
||||
|
||||
publish(event: Omit<SessionEvent, "seqNum" | "createdAt">): SessionEvent {
|
||||
if (this.closed) throw new Error("EventBus is closed");
|
||||
publish(event: Omit<SessionEvent, 'seqNum' | 'createdAt'>): SessionEvent {
|
||||
if (this.closed) throw new Error('EventBus is closed')
|
||||
const full: SessionEvent = {
|
||||
...event,
|
||||
seqNum: ++this.seqNum,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.events.push(full);
|
||||
}
|
||||
this.events.push(full)
|
||||
// Evict oldest events when exceeding limit
|
||||
if (this.events.length > MAX_EVENTS_PER_BUS) {
|
||||
this.events = this.events.slice(-Math.floor(MAX_EVENTS_PER_BUS / 2));
|
||||
this.events = this.events.slice(-Math.floor(MAX_EVENTS_PER_BUS / 2))
|
||||
}
|
||||
log(
|
||||
`[RC-DEBUG] bus publish: sessionId=${event.sessionId} type=${event.type} dir=${event.direction} seq=${full.seqNum} subscribers=${this.subscribers.size}`,
|
||||
event.type === "error" ? `payload=${JSON.stringify(event.payload)}` : "",
|
||||
);
|
||||
event.type === 'error' ? `payload=${JSON.stringify(event.payload)}` : '',
|
||||
)
|
||||
for (const cb of this.subscribers) {
|
||||
try {
|
||||
cb(full);
|
||||
cb(full)
|
||||
} catch (err) {
|
||||
logError(`[RC-DEBUG] bus subscriber error:`, err);
|
||||
logError(`[RC-DEBUG] bus subscriber error:`, err)
|
||||
}
|
||||
}
|
||||
return full;
|
||||
return full
|
||||
}
|
||||
|
||||
getLastSeqNum(): number {
|
||||
return this.seqNum;
|
||||
return this.seqNum
|
||||
}
|
||||
|
||||
getEventsSince(seqNum: number): SessionEvent[] {
|
||||
const idx = this.events.findIndex((e) => e.seqNum > seqNum);
|
||||
if (idx === -1) return [];
|
||||
return this.events.slice(idx);
|
||||
const idx = this.events.findIndex(e => e.seqNum > seqNum)
|
||||
if (idx === -1) return []
|
||||
return this.events.slice(idx)
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
this.subscribers.clear();
|
||||
this.closed = true
|
||||
this.subscribers.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** Global registry of per-session event buses */
|
||||
const buses = new Map<string, EventBus>();
|
||||
const buses = new Map<string, EventBus>()
|
||||
|
||||
export function getEventBus(sessionId: string): EventBus {
|
||||
let bus = buses.get(sessionId);
|
||||
let bus = buses.get(sessionId)
|
||||
if (!bus) {
|
||||
bus = new EventBus();
|
||||
buses.set(sessionId, bus);
|
||||
bus = new EventBus()
|
||||
buses.set(sessionId, bus)
|
||||
}
|
||||
return bus;
|
||||
return bus
|
||||
}
|
||||
|
||||
export function removeEventBus(sessionId: string) {
|
||||
const bus = buses.get(sessionId);
|
||||
const bus = buses.get(sessionId)
|
||||
if (bus) {
|
||||
bus.close();
|
||||
buses.delete(sessionId);
|
||||
bus.close()
|
||||
buses.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllEventBuses(): Map<string, EventBus> {
|
||||
return buses;
|
||||
return buses
|
||||
}
|
||||
|
||||
/** Global registry of per-channel-group ACP event buses */
|
||||
const acpBuses = new Map<string, EventBus>();
|
||||
const acpBuses = new Map<string, EventBus>()
|
||||
|
||||
export function getAcpEventBus(channelGroupId: string): EventBus {
|
||||
let bus = acpBuses.get(channelGroupId);
|
||||
let bus = acpBuses.get(channelGroupId)
|
||||
if (!bus) {
|
||||
bus = new EventBus();
|
||||
acpBuses.set(channelGroupId, bus);
|
||||
bus = new EventBus()
|
||||
acpBuses.set(channelGroupId, bus)
|
||||
}
|
||||
return bus;
|
||||
return bus
|
||||
}
|
||||
|
||||
export function removeAcpEventBus(channelGroupId: string) {
|
||||
const bus = acpBuses.get(channelGroupId);
|
||||
const bus = acpBuses.get(channelGroupId)
|
||||
if (bus) {
|
||||
bus.close();
|
||||
acpBuses.delete(channelGroupId);
|
||||
bus.close()
|
||||
acpBuses.delete(channelGroupId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,163 +1,177 @@
|
||||
import { log, error as logError } from "../logger";
|
||||
import type { Context } from "hono";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
import { log, error as logError } from '../logger'
|
||||
import type { Context } from 'hono'
|
||||
import type { SessionEvent } from './event-bus'
|
||||
import { getEventBus } from './event-bus'
|
||||
import { toClientPayload } from './client-payload'
|
||||
|
||||
export interface SSEWriter {
|
||||
send(event: SessionEvent): void;
|
||||
close(): void;
|
||||
send(event: SessionEvent): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export function createSSEWriter(c: Context): SSEWriter {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
c.req.raw.signal.addEventListener("abort", () => {
|
||||
controller.close();
|
||||
});
|
||||
const encoder = new TextEncoder()
|
||||
c.req.raw.signal.addEventListener('abort', () => {
|
||||
controller.close()
|
||||
})
|
||||
|
||||
// Store encoder and controller for later use
|
||||
(c as any)._sseEncoder = encoder;
|
||||
(c as any)._sseController = controller;
|
||||
;(c as any)._sseEncoder = encoder
|
||||
;(c as any)._sseController = controller
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return {
|
||||
send(event: SessionEvent) {
|
||||
const encoder = (c as any)._sseEncoder as TextEncoder;
|
||||
const controller = (c as any)._sseController as ReadableStreamDefaultController;
|
||||
if (!encoder || !controller) return;
|
||||
const encoder = (c as any)._sseEncoder as TextEncoder
|
||||
const controller = (c as any)
|
||||
._sseController as ReadableStreamDefaultController
|
||||
if (!encoder || !controller) return
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
direction: event.direction,
|
||||
seqNum: event.seqNum,
|
||||
});
|
||||
const msg = `id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`;
|
||||
controller.enqueue(encoder.encode(msg));
|
||||
})
|
||||
const msg = `id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`
|
||||
controller.enqueue(encoder.encode(msg))
|
||||
},
|
||||
close() {
|
||||
const controller = (c as any)._sseController as ReadableStreamDefaultController;
|
||||
controller?.close();
|
||||
const controller = (c as any)
|
||||
._sseController as ReadableStreamDefaultController
|
||||
controller?.close()
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Create SSE response stream for a session */
|
||||
export function createSSEStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
const bus = getEventBus(sessionId);
|
||||
const bus = getEventBus(sessionId)
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
// Send historical events if reconnecting
|
||||
if (fromSeqNum > 0) {
|
||||
const missed = bus.getEventsSince(fromSeqNum);
|
||||
const missed = bus.getEventsSince(fromSeqNum)
|
||||
for (const event of missed) {
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
direction: event.direction,
|
||||
seqNum: event.seqNum,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||
})
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial keepalive
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
controller.enqueue(encoder.encode(': keepalive\n\n'))
|
||||
|
||||
// Subscribe to new events
|
||||
const unsub = bus.subscribe((event) => {
|
||||
const unsub = bus.subscribe(event => {
|
||||
const data = JSON.stringify({
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
direction: event.direction,
|
||||
seqNum: event.seqNum,
|
||||
});
|
||||
})
|
||||
try {
|
||||
log(`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`);
|
||||
controller.enqueue(encoder.encode(`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`));
|
||||
log(
|
||||
`[RC-DEBUG] SSE -> web: sessionId=${sessionId} type=${event.type} dir=${event.direction} seq=${event.seqNum}`,
|
||||
)
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`id: ${event.seqNum}\nevent: message\ndata: ${data}\n\n`,
|
||||
),
|
||||
)
|
||||
} catch {
|
||||
unsub();
|
||||
unsub()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// Keepalive interval
|
||||
const keepalive = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
controller.enqueue(encoder.encode(': keepalive\n\n'))
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
unsub();
|
||||
clearInterval(keepalive)
|
||||
unsub()
|
||||
}
|
||||
}, 15000);
|
||||
}, 15000)
|
||||
|
||||
// Cleanup on abort
|
||||
c.req.raw.signal.addEventListener("abort", () => {
|
||||
unsub();
|
||||
clearInterval(keepalive);
|
||||
c.req.raw.signal.addEventListener('abort', () => {
|
||||
unsub()
|
||||
clearInterval(keepalive)
|
||||
try {
|
||||
controller.close();
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function toWorkerClientPayload(event: SessionEvent): Record<string, unknown> {
|
||||
if (
|
||||
event.type === "permission_response" ||
|
||||
event.type === "control_response" ||
|
||||
event.type === "control_request" ||
|
||||
event.type === "interrupt"
|
||||
event.type === 'permission_response' ||
|
||||
event.type === 'control_response' ||
|
||||
event.type === 'control_request' ||
|
||||
event.type === 'interrupt'
|
||||
) {
|
||||
return toClientPayload(event);
|
||||
return toClientPayload(event)
|
||||
}
|
||||
|
||||
const normalized =
|
||||
event.payload && typeof event.payload === "object"
|
||||
event.payload && typeof event.payload === 'object'
|
||||
? (event.payload as Record<string, unknown>)
|
||||
: undefined;
|
||||
: undefined
|
||||
const raw =
|
||||
normalized?.raw && typeof normalized.raw === "object" && !Array.isArray(normalized.raw)
|
||||
normalized?.raw &&
|
||||
typeof normalized.raw === 'object' &&
|
||||
!Array.isArray(normalized.raw)
|
||||
? (normalized.raw as Record<string, unknown>)
|
||||
: undefined;
|
||||
: undefined
|
||||
const payload: Record<string, unknown> = {
|
||||
...(raw ?? normalized ?? {}),
|
||||
type: event.type,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === "user") {
|
||||
const message = payload.message;
|
||||
if (!message || typeof message !== "object" || !("content" in message)) {
|
||||
if (event.type === 'user') {
|
||||
const message = payload.message
|
||||
if (!message || typeof message !== 'object' || !('content' in message)) {
|
||||
const content =
|
||||
typeof normalized?.content === "string"
|
||||
typeof normalized?.content === 'string'
|
||||
? normalized.content
|
||||
: typeof payload.content === "string"
|
||||
: typeof payload.content === 'string'
|
||||
? payload.content
|
||||
: typeof event.payload === "string"
|
||||
: typeof event.payload === 'string'
|
||||
? event.payload
|
||||
: "";
|
||||
payload.content = content;
|
||||
payload.message = { content };
|
||||
: ''
|
||||
payload.content = content
|
||||
payload.message = { content }
|
||||
}
|
||||
}
|
||||
|
||||
return payload;
|
||||
return payload
|
||||
}
|
||||
|
||||
function toWorkerClientFrame(event: SessionEvent): string {
|
||||
@@ -165,70 +179,74 @@ function toWorkerClientFrame(event: SessionEvent): string {
|
||||
event_id: event.id,
|
||||
sequence_num: event.seqNum,
|
||||
event_type: event.type,
|
||||
source: "client",
|
||||
source: 'client',
|
||||
payload: toWorkerClientPayload(event),
|
||||
created_at: new Date(event.createdAt).toISOString(),
|
||||
});
|
||||
return `id: ${event.seqNum}\nevent: client_event\ndata: ${data}\n\n`;
|
||||
})
|
||||
return `id: ${event.seqNum}\nevent: client_event\ndata: ${data}\n\n`
|
||||
}
|
||||
|
||||
/** Create CCR worker SSE stream (client_event frames, outbound events only). */
|
||||
export function createWorkerEventStream(c: Context, sessionId: string, fromSeqNum = 0) {
|
||||
const bus = getEventBus(sessionId);
|
||||
export function createWorkerEventStream(
|
||||
c: Context,
|
||||
sessionId: string,
|
||||
fromSeqNum = 0,
|
||||
) {
|
||||
const bus = getEventBus(sessionId)
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
if (fromSeqNum > 0) {
|
||||
const missed = bus
|
||||
.getEventsSince(fromSeqNum)
|
||||
.filter((event) => event.direction === "outbound");
|
||||
.filter(event => event.direction === 'outbound')
|
||||
for (const event of missed) {
|
||||
controller.enqueue(encoder.encode(toWorkerClientFrame(event)));
|
||||
controller.enqueue(encoder.encode(toWorkerClientFrame(event)))
|
||||
}
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
controller.enqueue(encoder.encode(': keepalive\n\n'))
|
||||
|
||||
const unsub = bus.subscribe((event) => {
|
||||
if (event.direction !== "outbound") {
|
||||
return;
|
||||
const unsub = bus.subscribe(event => {
|
||||
if (event.direction !== 'outbound') {
|
||||
return
|
||||
}
|
||||
try {
|
||||
controller.enqueue(encoder.encode(toWorkerClientFrame(event)));
|
||||
controller.enqueue(encoder.encode(toWorkerClientFrame(event)))
|
||||
} catch {
|
||||
unsub();
|
||||
unsub()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const keepalive = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
controller.enqueue(encoder.encode(': keepalive\n\n'))
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
unsub();
|
||||
clearInterval(keepalive)
|
||||
unsub()
|
||||
}
|
||||
}, 15000);
|
||||
}, 15000)
|
||||
|
||||
c.req.raw.signal.addEventListener("abort", () => {
|
||||
unsub();
|
||||
clearInterval(keepalive);
|
||||
c.req.raw.signal.addEventListener('abort', () => {
|
||||
unsub()
|
||||
clearInterval(keepalive)
|
||||
try {
|
||||
controller.close();
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import type { WSContext } from "hono/ws";
|
||||
import { getEventBus } from "./event-bus";
|
||||
import type { SessionEvent } from "./event-bus";
|
||||
import { publishSessionEvent } from "../services/transport";
|
||||
import { log, error as logError } from "../logger";
|
||||
import { toClientPayload } from "./client-payload";
|
||||
import { config } from "../config";
|
||||
import type { WSContext } from 'hono/ws'
|
||||
import { getEventBus } from './event-bus'
|
||||
import type { SessionEvent } from './event-bus'
|
||||
import { publishSessionEvent } from '../services/transport'
|
||||
import { log, error as logError } from '../logger'
|
||||
import { toClientPayload } from './client-payload'
|
||||
import { config } from '../config'
|
||||
|
||||
// Per-connection cleanup, keyed by sessionId (only one WS per session)
|
||||
interface CleanupEntry {
|
||||
unsub: () => void;
|
||||
keepalive: ReturnType<typeof setInterval>;
|
||||
ws: WSContext;
|
||||
openTime: number;
|
||||
lastClientActivity: number;
|
||||
unsub: () => void
|
||||
keepalive: ReturnType<typeof setInterval>
|
||||
ws: WSContext
|
||||
openTime: number
|
||||
lastClientActivity: number
|
||||
}
|
||||
const cleanupBySession = new Map<string, CleanupEntry>();
|
||||
const cleanupBySession = new Map<string, CleanupEntry>()
|
||||
|
||||
// Track all active WS connections for graceful shutdown
|
||||
const activeConnections = new Set<WSContext>();
|
||||
const activeConnections = new Set<WSContext>()
|
||||
|
||||
// Server-side keepalive interval (configurable via RCS_WS_KEEPALIVE_INTERVAL).
|
||||
// Sends data frames to keep reverse proxies from closing idle connections.
|
||||
const SERVER_KEEPALIVE_INTERVAL_MS = (config.wsKeepaliveInterval || 20) * 1000;
|
||||
const SERVER_KEEPALIVE_INTERVAL_MS = (config.wsKeepaliveInterval || 20) * 1000
|
||||
|
||||
// If no client data received within this threshold, the connection is
|
||||
// considered dead. Set to 3x keepalive to tolerate one missed interval.
|
||||
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
|
||||
const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3
|
||||
|
||||
/**
|
||||
* Convert internal EventBus event -> SDK message for bridge client.
|
||||
@@ -33,36 +33,36 @@ const CLIENT_ACTIVITY_TIMEOUT_MS = SERVER_KEEPALIVE_INTERVAL_MS * 3;
|
||||
function toSDKMessage(event: SessionEvent): string {
|
||||
// NDJSON format: each message MUST end with \n so the child process's
|
||||
// line-based parser can split messages correctly.
|
||||
return JSON.stringify(toClientPayload(event)) + "\n";
|
||||
return JSON.stringify(toClientPayload(event)) + '\n'
|
||||
}
|
||||
|
||||
/** Called from onOpen — subscribes to event bus, forwards outbound events to bridge WS */
|
||||
export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
const openTime = Date.now();
|
||||
const lastClientActivity = Date.now();
|
||||
log(`[RC-DEBUG] [WS] Open session=${sessionId}`);
|
||||
activeConnections.add(ws);
|
||||
const openTime = Date.now()
|
||||
const lastClientActivity = Date.now()
|
||||
log(`[RC-DEBUG] [WS] Open session=${sessionId}`)
|
||||
activeConnections.add(ws)
|
||||
|
||||
// If there's an existing connection for this session, clean it up first
|
||||
const existing = cleanupBySession.get(sessionId);
|
||||
const existing = cleanupBySession.get(sessionId)
|
||||
if (existing) {
|
||||
log(`[WS] Replacing existing connection for session=${sessionId}`);
|
||||
existing.unsub();
|
||||
clearInterval(existing.keepalive);
|
||||
activeConnections.delete(existing.ws);
|
||||
log(`[WS] Replacing existing connection for session=${sessionId}`)
|
||||
existing.unsub()
|
||||
clearInterval(existing.keepalive)
|
||||
activeConnections.delete(existing.ws)
|
||||
}
|
||||
|
||||
const bus = getEventBus(sessionId);
|
||||
const bus = getEventBus(sessionId)
|
||||
|
||||
// Replay ALL events (inbound + outbound) so the bridge can reconstruct
|
||||
// the full conversation history — assistant replies are inbound events.
|
||||
const missed = bus.getEventsSince(0);
|
||||
const missed = bus.getEventsSince(0)
|
||||
if (missed.length > 0) {
|
||||
log(`[WS] Replaying ${missed.length} missed event(s)`);
|
||||
log(`[WS] Replaying ${missed.length} missed event(s)`)
|
||||
for (const event of missed) {
|
||||
if (ws.readyState !== 1) break;
|
||||
if (ws.readyState !== 1) break
|
||||
try {
|
||||
ws.send(toSDKMessage(event));
|
||||
ws.send(toSDKMessage(event))
|
||||
} catch {
|
||||
// ignore send errors during replay
|
||||
}
|
||||
@@ -70,75 +70,96 @@ export function handleWebSocketOpen(ws: WSContext, sessionId: string) {
|
||||
}
|
||||
|
||||
const unsub = bus.subscribe((event: SessionEvent) => {
|
||||
if (ws.readyState !== 1) return;
|
||||
if (event.direction !== "outbound") return;
|
||||
if (ws.readyState !== 1) return
|
||||
if (event.direction !== 'outbound') return
|
||||
try {
|
||||
const sdkMsg = toSDKMessage(event);
|
||||
log(`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`);
|
||||
ws.send(sdkMsg);
|
||||
const sdkMsg = toSDKMessage(event)
|
||||
log(
|
||||
`[RC-DEBUG] [WS] -> bridge (outbound): type=${event.type} len=${sdkMsg.length} msg=${sdkMsg.slice(0, 300)}`,
|
||||
)
|
||||
ws.send(sdkMsg)
|
||||
} catch (err) {
|
||||
logError("[RC-DEBUG] [WS] send error:", err);
|
||||
logError('[RC-DEBUG] [WS] send error:', err)
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
const keepalive = setInterval(() => {
|
||||
if (ws.readyState !== 1) {
|
||||
clearInterval(keepalive);
|
||||
return;
|
||||
clearInterval(keepalive)
|
||||
return
|
||||
}
|
||||
// Check if client is still alive — close if no data received for too long
|
||||
const silenceMs = Date.now() - lastClientActivity;
|
||||
const silenceMs = Date.now() - lastClientActivity
|
||||
if (silenceMs > CLIENT_ACTIVITY_TIMEOUT_MS) {
|
||||
log(`[WS] Client inactive for ${Math.round(silenceMs / 1000)}s on session=${sessionId}, closing dead connection`);
|
||||
log(
|
||||
`[WS] Client inactive for ${Math.round(silenceMs / 1000)}s on session=${sessionId}, closing dead connection`,
|
||||
)
|
||||
try {
|
||||
ws.close(1000, "client inactive");
|
||||
ws.close(1000, 'client inactive')
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
clearInterval(keepalive)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
try {
|
||||
ws.send('{"type":"keep_alive"}\n');
|
||||
ws.send('{"type":"keep_alive"}\n')
|
||||
} catch {
|
||||
clearInterval(keepalive);
|
||||
clearInterval(keepalive)
|
||||
}
|
||||
}, SERVER_KEEPALIVE_INTERVAL_MS);
|
||||
}, SERVER_KEEPALIVE_INTERVAL_MS)
|
||||
|
||||
cleanupBySession.set(sessionId, { unsub, keepalive, ws, openTime, lastClientActivity });
|
||||
cleanupBySession.set(sessionId, {
|
||||
unsub,
|
||||
keepalive,
|
||||
ws,
|
||||
openTime,
|
||||
lastClientActivity,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from onMessage — bridge sends newline-delimited JSON.
|
||||
*/
|
||||
export function handleWebSocketMessage(ws: WSContext, sessionId: string, data: string) {
|
||||
export function handleWebSocketMessage(
|
||||
ws: WSContext,
|
||||
sessionId: string,
|
||||
data: string,
|
||||
) {
|
||||
// Track client activity for dead-connection detection
|
||||
const entry = cleanupBySession.get(sessionId);
|
||||
const entry = cleanupBySession.get(sessionId)
|
||||
if (entry) {
|
||||
entry.lastClientActivity = Date.now();
|
||||
entry.lastClientActivity = Date.now()
|
||||
}
|
||||
const lines = data.split("\n").filter((l) => l.trim());
|
||||
const lines = data.split('\n').filter(l => l.trim())
|
||||
for (const line of lines) {
|
||||
try {
|
||||
ingestBridgeMessage(sessionId, JSON.parse(line));
|
||||
ingestBridgeMessage(sessionId, JSON.parse(line))
|
||||
} catch (err) {
|
||||
logError("[WS] parse error:", err);
|
||||
logError('[WS] parse error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called from onClose — unsubscribes from event bus */
|
||||
export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: number, reason?: string) {
|
||||
activeConnections.delete(ws);
|
||||
export function handleWebSocketClose(
|
||||
ws: WSContext,
|
||||
sessionId: string,
|
||||
code?: number,
|
||||
reason?: string,
|
||||
) {
|
||||
activeConnections.delete(ws)
|
||||
|
||||
const entry = cleanupBySession.get(sessionId);
|
||||
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1;
|
||||
const entry = cleanupBySession.get(sessionId)
|
||||
const duration = entry ? Math.round((Date.now() - entry.openTime) / 1000) : -1
|
||||
|
||||
log(`[WS] Close session=${sessionId} code=${code ?? "none"} reason=${reason || "(none)"} duration=${duration}s`);
|
||||
log(
|
||||
`[WS] Close session=${sessionId} code=${code ?? 'none'} reason=${reason || '(none)'} duration=${duration}s`,
|
||||
)
|
||||
|
||||
if (entry) {
|
||||
entry.unsub();
|
||||
clearInterval(entry.keepalive);
|
||||
cleanupBySession.delete(sessionId);
|
||||
entry.unsub()
|
||||
clearInterval(entry.keepalive)
|
||||
cleanupBySession.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,88 +171,104 @@ export function handleWebSocketClose(ws: WSContext, sessionId: string, code?: nu
|
||||
* {"subtype":"success","uuid":"...","result":"..."} → type "result"
|
||||
*/
|
||||
function deriveEventType(msg: Record<string, unknown>): string {
|
||||
if (msg.type && typeof msg.type === "string") return msg.type;
|
||||
if (msg.type && typeof msg.type === 'string') return msg.type
|
||||
|
||||
// Child process stream-json format: message.role determines type
|
||||
const message = msg.message as Record<string, unknown> | undefined;
|
||||
if (message && typeof message.role === "string") {
|
||||
return message.role; // "user", "assistant", "system"
|
||||
const message = msg.message as Record<string, unknown> | undefined
|
||||
if (message && typeof message.role === 'string') {
|
||||
return message.role // "user", "assistant", "system"
|
||||
}
|
||||
|
||||
// Result message
|
||||
if (msg.subtype || msg.result !== undefined) return "result";
|
||||
if (msg.subtype || msg.result !== undefined) return 'result'
|
||||
|
||||
// System/init message
|
||||
if (msg.session_id) return "system";
|
||||
if (msg.session_id) return 'system'
|
||||
|
||||
return "unknown";
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single SDK message from bridge -> publish to EventBus as inbound.
|
||||
*/
|
||||
export function ingestBridgeMessage(sessionId: string, msg: Record<string, unknown>) {
|
||||
if (msg.type === "keep_alive") return;
|
||||
export function ingestBridgeMessage(
|
||||
sessionId: string,
|
||||
msg: Record<string, unknown>,
|
||||
) {
|
||||
if (msg.type === 'keep_alive') return
|
||||
|
||||
const eventType = deriveEventType(msg);
|
||||
const eventType = deriveEventType(msg)
|
||||
|
||||
log(`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ""} msg=${JSON.stringify(msg).slice(0, 300)}`);
|
||||
log(
|
||||
`[RC-DEBUG] [WS] <- bridge (inbound): sessionId=${sessionId} type=${eventType}${msg.uuid ? ` uuid=${msg.uuid}` : ''} msg=${JSON.stringify(msg).slice(0, 300)}`,
|
||||
)
|
||||
|
||||
let payload: unknown;
|
||||
let payload: unknown
|
||||
|
||||
if (eventType === "assistant" || eventType === "partial_assistant") {
|
||||
const message = msg.message as Record<string, unknown> | undefined;
|
||||
const content = message?.content;
|
||||
if (eventType === 'assistant' || eventType === 'partial_assistant') {
|
||||
const message = msg.message as Record<string, unknown> | undefined
|
||||
const content = message?.content
|
||||
// Extract text from content blocks for simple display
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
let text = ''
|
||||
if (typeof content === 'string') {
|
||||
text = content
|
||||
} else if (Array.isArray(content)) {
|
||||
text = content
|
||||
.filter((b: unknown) => b && typeof b === "object" && "type" in (b as Record<string, unknown>) && (b as Record<string, unknown>).type === "text")
|
||||
.map((b: Record<string, unknown>) => (b as Record<string, unknown>).text || "")
|
||||
.join("");
|
||||
.filter(
|
||||
(b: unknown) =>
|
||||
b &&
|
||||
typeof b === 'object' &&
|
||||
'type' in (b as Record<string, unknown>) &&
|
||||
(b as Record<string, unknown>).type === 'text',
|
||||
)
|
||||
.map(
|
||||
(b: Record<string, unknown>) =>
|
||||
(b as Record<string, unknown>).text || '',
|
||||
)
|
||||
.join('')
|
||||
}
|
||||
payload = { message: msg.message, uuid: msg.uuid, content: text };
|
||||
} else if (eventType === "user" || eventType === "system") {
|
||||
payload = { message: msg.message, uuid: msg.uuid, content: text }
|
||||
} else if (eventType === 'user' || eventType === 'system') {
|
||||
payload = {
|
||||
message: msg.message,
|
||||
uuid: msg.uuid,
|
||||
...(typeof msg.isSynthetic === "boolean" ? { isSynthetic: msg.isSynthetic } : {}),
|
||||
};
|
||||
} else if (eventType === "control_request") {
|
||||
payload = { request_id: msg.request_id, request: msg.request };
|
||||
} else if (eventType === "control_response") {
|
||||
payload = { response: msg.response };
|
||||
} else if (eventType === "result" || eventType === "result_success") {
|
||||
payload = { subtype: msg.subtype, uuid: msg.uuid, result: msg.result };
|
||||
...(typeof msg.isSynthetic === 'boolean'
|
||||
? { isSynthetic: msg.isSynthetic }
|
||||
: {}),
|
||||
}
|
||||
} else if (eventType === 'control_request') {
|
||||
payload = { request_id: msg.request_id, request: msg.request }
|
||||
} else if (eventType === 'control_response') {
|
||||
payload = { response: msg.response }
|
||||
} else if (eventType === 'result' || eventType === 'result_success') {
|
||||
payload = { subtype: msg.subtype, uuid: msg.uuid, result: msg.result }
|
||||
} else {
|
||||
payload = msg;
|
||||
payload = msg
|
||||
}
|
||||
|
||||
publishSessionEvent(sessionId, eventType, payload, "inbound");
|
||||
publishSessionEvent(sessionId, eventType, payload, 'inbound')
|
||||
}
|
||||
|
||||
/**
|
||||
* Gracefully close all active WebSocket connections.
|
||||
*/
|
||||
export function closeAllConnections(): void {
|
||||
const count = activeConnections.size;
|
||||
if (count === 0) return;
|
||||
const count = activeConnections.size
|
||||
if (count === 0) return
|
||||
|
||||
log(`[WS] Gracefully closing ${count} active connection(s)...`);
|
||||
log(`[WS] Gracefully closing ${count} active connection(s)...`)
|
||||
for (const [sessionId, entry] of cleanupBySession) {
|
||||
try {
|
||||
entry.unsub();
|
||||
clearInterval(entry.keepalive);
|
||||
entry.unsub()
|
||||
clearInterval(entry.keepalive)
|
||||
if (entry.ws.readyState === 1) {
|
||||
entry.ws.close(1001, "server_shutdown");
|
||||
entry.ws.close(1001, 'server_shutdown')
|
||||
}
|
||||
} catch {
|
||||
// ignore errors during shutdown
|
||||
}
|
||||
}
|
||||
cleanupBySession.clear();
|
||||
activeConnections.clear();
|
||||
log("[WS] All connections closed");
|
||||
cleanupBySession.clear()
|
||||
activeConnections.clear()
|
||||
log('[WS] All connections closed')
|
||||
}
|
||||
|
||||
@@ -1,39 +1,42 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import type { WSContext } from "hono/ws";
|
||||
import { error as logError } from "../logger";
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { WSContext } from 'hono/ws'
|
||||
import { error as logError } from '../logger'
|
||||
|
||||
const textDecoder = new TextDecoder();
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024;
|
||||
export const MAX_WS_MESSAGE_SIZE = 10 * 1024 * 1024
|
||||
|
||||
export type DecodedWsMessage =
|
||||
| { ok: true; data: string; size: number }
|
||||
| { ok: false; reason: string; size?: number };
|
||||
| { ok: false; reason: string; size?: number }
|
||||
|
||||
export function decodeWsPayload(data: unknown): DecodedWsMessage {
|
||||
if (typeof data === "string") {
|
||||
return { ok: true, data, size: Buffer.byteLength(data, "utf8") };
|
||||
if (typeof data === 'string') {
|
||||
return { ok: true, data, size: Buffer.byteLength(data, 'utf8') }
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||
return { ok: false, reason: "message too large", size: data.byteLength };
|
||||
return { ok: false, reason: 'message too large', size: data.byteLength }
|
||||
}
|
||||
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
|
||||
return { ok: true, data: textDecoder.decode(data), size: data.byteLength }
|
||||
}
|
||||
if (data instanceof Uint8Array) {
|
||||
if (data.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||
return { ok: false, reason: "message too large", size: data.byteLength };
|
||||
return { ok: false, reason: 'message too large', size: data.byteLength }
|
||||
}
|
||||
return { ok: true, data: textDecoder.decode(data), size: data.byteLength };
|
||||
return { ok: true, data: textDecoder.decode(data), size: data.byteLength }
|
||||
}
|
||||
if (typeof SharedArrayBuffer !== "undefined" && data instanceof SharedArrayBuffer) {
|
||||
const bytes = new Uint8Array(data);
|
||||
if (
|
||||
typeof SharedArrayBuffer !== 'undefined' &&
|
||||
data instanceof SharedArrayBuffer
|
||||
) {
|
||||
const bytes = new Uint8Array(data)
|
||||
if (bytes.byteLength > MAX_WS_MESSAGE_SIZE) {
|
||||
return { ok: false, reason: "message too large", size: bytes.byteLength };
|
||||
return { ok: false, reason: 'message too large', size: bytes.byteLength }
|
||||
}
|
||||
return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength };
|
||||
return { ok: true, data: textDecoder.decode(bytes), size: bytes.byteLength }
|
||||
}
|
||||
return { ok: false, reason: typeof data };
|
||||
return { ok: false, reason: typeof data }
|
||||
}
|
||||
|
||||
export function handleSizedWsPayload(
|
||||
@@ -43,22 +46,28 @@ export function handleSizedWsPayload(
|
||||
payload: unknown,
|
||||
handleMessage: (data: string) => void,
|
||||
): boolean {
|
||||
const decoded = decodeWsPayload(payload);
|
||||
const decoded = decodeWsPayload(payload)
|
||||
if (!decoded.ok) {
|
||||
if (decoded.reason === "message too large" && decoded.size !== undefined) {
|
||||
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
|
||||
ws.close(1009, "message too large");
|
||||
return false;
|
||||
if (decoded.reason === 'message too large' && decoded.size !== undefined) {
|
||||
logError(
|
||||
`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`,
|
||||
)
|
||||
ws.close(1009, 'message too large')
|
||||
return false
|
||||
}
|
||||
logError(`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`);
|
||||
ws.close(1003, "unsupported message payload");
|
||||
return false;
|
||||
logError(
|
||||
`${logPrefix} Unsupported message payload on ${label}: ${decoded.reason}`,
|
||||
)
|
||||
ws.close(1003, 'unsupported message payload')
|
||||
return false
|
||||
}
|
||||
if (decoded.size > MAX_WS_MESSAGE_SIZE) {
|
||||
logError(`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`);
|
||||
ws.close(1009, "message too large");
|
||||
return false;
|
||||
logError(
|
||||
`${logPrefix} Message too large on ${label}: size=${decoded.size} limit=${MAX_WS_MESSAGE_SIZE}`,
|
||||
)
|
||||
ws.close(1009, 'message too large')
|
||||
return false
|
||||
}
|
||||
handleMessage(decoded.data);
|
||||
return true;
|
||||
handleMessage(decoded.data)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { upgradeWebSocket, websocket } from "hono/bun";
|
||||
export { upgradeWebSocket, websocket } from 'hono/bun'
|
||||
|
||||
@@ -1,159 +1,159 @@
|
||||
/** API 请求/响应类型定义 */
|
||||
|
||||
// Hono context variable types
|
||||
declare module "hono" {
|
||||
declare module 'hono' {
|
||||
interface ContextVariableMap {
|
||||
username?: string;
|
||||
uuid?: string;
|
||||
jwtPayload?: { session_id: string; role: string; iat: number; exp: number };
|
||||
username?: string
|
||||
uuid?: string
|
||||
jwtPayload?: { session_id: string; role: string; iat: number; exp: number }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Environment ---
|
||||
|
||||
export interface RegisterEnvironmentRequest {
|
||||
machine_name?: string;
|
||||
directory?: string;
|
||||
branch?: string;
|
||||
git_repo_url?: string;
|
||||
max_sessions?: number;
|
||||
worker_type?: string;
|
||||
bridge_id?: string;
|
||||
capabilities?: Record<string, unknown>;
|
||||
machine_name?: string
|
||||
directory?: string
|
||||
branch?: string
|
||||
git_repo_url?: string
|
||||
max_sessions?: number
|
||||
worker_type?: string
|
||||
bridge_id?: string
|
||||
capabilities?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RegisterEnvironmentResponse {
|
||||
id: string;
|
||||
secret: string;
|
||||
status: string;
|
||||
id: string
|
||||
secret: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface WorkResponse {
|
||||
id: string;
|
||||
type: "work";
|
||||
environment_id: string;
|
||||
state: string;
|
||||
id: string
|
||||
type: 'work'
|
||||
environment_id: string
|
||||
state: string
|
||||
data: {
|
||||
type: "session" | "healthcheck";
|
||||
id: string;
|
||||
};
|
||||
secret: string;
|
||||
created_at: string;
|
||||
type: 'session' | 'healthcheck'
|
||||
id: string
|
||||
}
|
||||
secret: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface WorkSecretPayload {
|
||||
version: number;
|
||||
session_ingress_token: string;
|
||||
api_base_url: string;
|
||||
sources: string[];
|
||||
auth: string[];
|
||||
use_code_sessions: boolean;
|
||||
version: number
|
||||
session_ingress_token: string
|
||||
api_base_url: string
|
||||
sources: string[]
|
||||
auth: string[]
|
||||
use_code_sessions: boolean
|
||||
}
|
||||
|
||||
// --- Session ---
|
||||
|
||||
export interface CreateSessionRequest {
|
||||
environment_id?: string | null;
|
||||
title?: string;
|
||||
events?: unknown[];
|
||||
source?: string;
|
||||
permission_mode?: string;
|
||||
environment_id?: string | null
|
||||
title?: string
|
||||
events?: unknown[]
|
||||
source?: string
|
||||
permission_mode?: string
|
||||
}
|
||||
|
||||
export interface SessionResponse {
|
||||
id: string;
|
||||
environment_id: string | null;
|
||||
title: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
permission_mode: string | null;
|
||||
worker_epoch: number;
|
||||
username: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
automation_state?: AutomationStateResponse;
|
||||
id: string
|
||||
environment_id: string | null
|
||||
title: string | null
|
||||
status: string
|
||||
source: string
|
||||
permission_mode: string | null
|
||||
worker_epoch: number
|
||||
username: string | null
|
||||
created_at: number
|
||||
updated_at: number
|
||||
automation_state?: AutomationStateResponse
|
||||
}
|
||||
|
||||
export interface AutomationStateResponse {
|
||||
enabled: boolean;
|
||||
phase: "standby" | "sleeping" | null;
|
||||
next_tick_at: number | null;
|
||||
sleep_until: number | null;
|
||||
enabled: boolean
|
||||
phase: 'standby' | 'sleeping' | null
|
||||
next_tick_at: number | null
|
||||
sleep_until: number | null
|
||||
}
|
||||
|
||||
// --- v2 Code Sessions ---
|
||||
|
||||
export interface CreateCodeSessionRequest {
|
||||
title?: string;
|
||||
source?: string;
|
||||
permission_mode?: string;
|
||||
title?: string
|
||||
source?: string
|
||||
permission_mode?: string
|
||||
}
|
||||
|
||||
export interface BridgeResponse {
|
||||
api_base_url: string;
|
||||
worker_epoch: number;
|
||||
worker_jwt: string;
|
||||
expires_in: number;
|
||||
api_base_url: string
|
||||
worker_epoch: number
|
||||
worker_jwt: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
// --- Web ---
|
||||
|
||||
export interface EnvironmentResponse {
|
||||
id: string;
|
||||
machine_name: string | null;
|
||||
directory: string | null;
|
||||
branch: string | null;
|
||||
status: string;
|
||||
username: string | null;
|
||||
last_poll_at: number | null;
|
||||
worker_type?: string;
|
||||
channel_group_id?: string | null;
|
||||
capabilities?: Record<string, unknown> | null;
|
||||
id: string
|
||||
machine_name: string | null
|
||||
directory: string | null
|
||||
branch: string | null
|
||||
status: string
|
||||
username: string | null
|
||||
last_poll_at: number | null
|
||||
worker_type?: string
|
||||
channel_group_id?: string | null
|
||||
capabilities?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface SessionSummaryResponse {
|
||||
id: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
username: string | null;
|
||||
updated_at: number;
|
||||
id: string
|
||||
title: string | null
|
||||
status: string
|
||||
username: string | null
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
// --- Web Auth ---
|
||||
|
||||
export interface WebLoginRequest {
|
||||
apiKey: string;
|
||||
username: string;
|
||||
apiKey: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface WebLoginResponse {
|
||||
token: string;
|
||||
expires_in: number;
|
||||
token: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface WebControlRequest {
|
||||
type: string;
|
||||
content?: string;
|
||||
[key: string]: unknown;
|
||||
type: string
|
||||
content?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// --- Error ---
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
// --- Event ---
|
||||
|
||||
export interface SessionEventPayload {
|
||||
id: string;
|
||||
session_id: string;
|
||||
type: string;
|
||||
payload: unknown;
|
||||
direction: "inbound" | "outbound";
|
||||
seq_num: number;
|
||||
created_at: number;
|
||||
id: string
|
||||
session_id: string
|
||||
type: string
|
||||
payload: unknown
|
||||
direction: 'inbound' | 'outbound'
|
||||
seq_num: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
@@ -1,83 +1,83 @@
|
||||
/** SDK 消息类型 — 与 CC CLI bridge 模块兼容 */
|
||||
export interface SDKMessage {
|
||||
type: string;
|
||||
content?: unknown;
|
||||
[key: string]: unknown;
|
||||
type: string
|
||||
content?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UserMessage extends SDKMessage {
|
||||
type: "user";
|
||||
content: string;
|
||||
type: 'user'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface AssistantMessage extends SDKMessage {
|
||||
type: "assistant";
|
||||
content: string;
|
||||
type: 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface PermissionRequest extends SDKMessage {
|
||||
type: "permission_request";
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
type: 'permission_request'
|
||||
tool_name: string
|
||||
tool_input: unknown
|
||||
}
|
||||
|
||||
export interface PermissionResponse extends SDKMessage {
|
||||
type: "permission_response";
|
||||
approved: boolean;
|
||||
request_id: string;
|
||||
type: 'permission_response'
|
||||
approved: boolean
|
||||
request_id: string
|
||||
}
|
||||
|
||||
export interface ControlRequest extends SDKMessage {
|
||||
type: "control_request";
|
||||
action: string;
|
||||
[key: string]: unknown;
|
||||
type: 'control_request'
|
||||
action: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type SessionEventType =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "automation_state"
|
||||
| "permission_request"
|
||||
| "permission_response"
|
||||
| "control_request"
|
||||
| "tool_use"
|
||||
| "tool_result"
|
||||
| "status"
|
||||
| "error";
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
| 'automation_state'
|
||||
| 'permission_request'
|
||||
| 'permission_response'
|
||||
| 'control_request'
|
||||
| 'tool_use'
|
||||
| 'tool_result'
|
||||
| 'status'
|
||||
| 'error'
|
||||
|
||||
// --- Normalized Event Payloads (SSE contract) ---
|
||||
|
||||
export interface NormalizedEventPayload {
|
||||
content: string;
|
||||
raw?: unknown;
|
||||
isSynthetic?: boolean;
|
||||
[key: string]: unknown;
|
||||
content: string
|
||||
raw?: unknown
|
||||
isSynthetic?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UserEventPayload extends NormalizedEventPayload {
|
||||
content: string;
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface AssistantEventPayload extends NormalizedEventPayload {
|
||||
content: string;
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface ToolUseEventPayload extends NormalizedEventPayload {
|
||||
content: string;
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
content: string
|
||||
tool_name: string
|
||||
tool_input: unknown
|
||||
}
|
||||
|
||||
export interface ToolResultEventPayload extends NormalizedEventPayload {
|
||||
content: string;
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface PermissionEventPayload extends NormalizedEventPayload {
|
||||
content: string;
|
||||
request_id: string;
|
||||
content: string
|
||||
request_id: string
|
||||
request: {
|
||||
subtype: string;
|
||||
tool_name: string;
|
||||
tool_input: unknown;
|
||||
};
|
||||
subtype: string
|
||||
tool_name: string
|
||||
tool_input: unknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "./ui/button";
|
||||
import { StatusDot } from "./ui/connection-status";
|
||||
import { ThemeToggle } from "./ui/theme-toggle";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "./ui/input-group";
|
||||
import { ACPClient, DEFAULT_SETTINGS, DisconnectRequestedError } from "../src/acp";
|
||||
import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult } from "../src/acp";
|
||||
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from "lucide-react";
|
||||
import { useQRScanner, type QRCodeData } from "../src/hooks";
|
||||
import { useState, useEffect, useLayoutEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from './ui/button';
|
||||
import { StatusDot } from './ui/connection-status';
|
||||
import { ThemeToggle } from './ui/theme-toggle';
|
||||
import { Label } from './ui/label';
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput } from './ui/input-group';
|
||||
import { ACPClient, DEFAULT_SETTINGS, DisconnectRequestedError } from '../src/acp';
|
||||
import type { ACPSettings, ConnectionState, BrowserToolParams, BrowserToolResult } from '../src/acp';
|
||||
import { ChevronDown, FolderOpen, Globe, Image, KeyRound, ScanLine, X } from 'lucide-react';
|
||||
import { useQRScanner, type QRCodeData } from '../src/hooks';
|
||||
|
||||
// Get token from the URL fragment so it is not sent in HTTP requests.
|
||||
function getTokenFromUrl(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
return hashParams.get("token") || undefined;
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
|
||||
return hashParams.get('token') || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -30,12 +26,12 @@ function getTokenFromUrl(): string | undefined {
|
||||
function inferProxyUrlFromPage(): string | undefined {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
|
||||
// Only infer if we have a fragment token (indicates user came from server-printed URL)
|
||||
if (!hashParams.has("token")) {
|
||||
if (!hashParams.has('token')) {
|
||||
return undefined;
|
||||
}
|
||||
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
const protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${url.host}/ws`;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -45,15 +41,15 @@ function inferProxyUrlFromPage(): string | undefined {
|
||||
function scrubTokenFromUrl(): void {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ""));
|
||||
if (!hashParams.has("token")) {
|
||||
const hashParams = new URLSearchParams(url.hash.replace(/^#/, ''));
|
||||
if (!hashParams.has('token')) {
|
||||
return;
|
||||
}
|
||||
|
||||
hashParams.delete("token");
|
||||
hashParams.delete('token');
|
||||
const nextHash = hashParams.toString();
|
||||
url.hash = nextHash ? `#${nextHash}` : "";
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
url.hash = nextHash ? `#${nextHash}` : '';
|
||||
window.history.replaceState(null, '', url.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -102,11 +98,11 @@ export function ACPConnect({
|
||||
browserToolHandler,
|
||||
showTokenInput = false,
|
||||
inferFromUrl = false,
|
||||
placeholder = "Proxy server URL",
|
||||
placeholder = 'Proxy server URL',
|
||||
showScanButton = false,
|
||||
}: ACPConnectProps) {
|
||||
const [settings, setSettings] = useState<ACPSettings>(() => getInitialSettings(inferFromUrl));
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
|
||||
const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isShaking, setIsShaking] = useState(false);
|
||||
const [client, setClient] = useState<ACPClient | null>(null);
|
||||
@@ -122,7 +118,7 @@ export function ACPConnect({
|
||||
// Mark for auto-connect (will be triggered by settings useEffect)
|
||||
pendingAutoConnectRef.current = true;
|
||||
// Update settings - this will trigger auto-connect via useEffect
|
||||
setSettings((prev) => ({
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
proxyUrl: data.url,
|
||||
token: data.token,
|
||||
@@ -163,9 +159,9 @@ export function ACPConnect({
|
||||
stopScanning(); // Close the scanner overlay after album scan
|
||||
}
|
||||
// Reset input to allow re-selecting the same file
|
||||
e.target.value = "";
|
||||
e.target.value = '';
|
||||
},
|
||||
[scanFromFile, stopScanning]
|
||||
[scanFromFile, stopScanning],
|
||||
);
|
||||
|
||||
// Open file picker
|
||||
@@ -203,7 +199,7 @@ export function ACPConnect({
|
||||
// Auto-connect after QR scan (when pendingAutoConnectRef is set)
|
||||
if (pendingAutoConnectRef.current) {
|
||||
pendingAutoConnectRef.current = false;
|
||||
client.connect().catch((e) => {
|
||||
client.connect().catch(e => {
|
||||
// Ignore disconnect requested - user cancelled intentionally
|
||||
if (e instanceof DisconnectRequestedError) {
|
||||
return;
|
||||
@@ -219,7 +215,7 @@ export function ACPConnect({
|
||||
|
||||
// Notify parent when client is ready and auto-collapse on connect
|
||||
useEffect(() => {
|
||||
const isConnected = connectionState === "connected";
|
||||
const isConnected = connectionState === 'connected';
|
||||
onClientReady?.(isConnected ? client : null);
|
||||
|
||||
// Auto-collapse when connected for the first time
|
||||
@@ -229,14 +225,14 @@ export function ACPConnect({
|
||||
}
|
||||
|
||||
// Reset auto-collapse flag when disconnected
|
||||
if (connectionState === "disconnected") {
|
||||
if (connectionState === 'disconnected') {
|
||||
hasAutoCollapsedRef.current = false;
|
||||
}
|
||||
}, [connectionState, client, onClientReady, onExpandedChange]);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
// Prevent duplicate connect calls if already connecting or connected
|
||||
if (!client || connectionState === "connecting" || connectionState === "connected") {
|
||||
if (!client || connectionState === 'connecting' || connectionState === 'connected') {
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -263,7 +259,7 @@ export function ACPConnect({
|
||||
}, [client]);
|
||||
|
||||
const updateSetting = <K extends keyof ACPSettings>(key: K, value: ACPSettings[K]) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Clear error when starting to scan
|
||||
@@ -272,107 +268,93 @@ export function ACPConnect({
|
||||
startScanning();
|
||||
}, [startScanning]);
|
||||
|
||||
const isConnected = connectionState === "connected";
|
||||
const isConnecting = connectionState === "connecting";
|
||||
const isConnected = connectionState === 'connected';
|
||||
const isConnecting = connectionState === 'connecting';
|
||||
|
||||
const handleInputKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isConnected && !isConnecting) {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}
|
||||
}, [isConnected, isConnecting, handleConnect]);
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isConnected && !isConnecting) {
|
||||
e.preventDefault();
|
||||
handleConnect();
|
||||
}
|
||||
},
|
||||
[isConnected, isConnecting, handleConnect],
|
||||
);
|
||||
|
||||
// Format URL for display
|
||||
const displayUrl = settings.proxyUrl.replace(/^wss?:\/\//, "").replace(/\/ws$/, "");
|
||||
const displayUrl = settings.proxyUrl.replace(/^wss?:\/\//, '').replace(/\/ws$/, '');
|
||||
|
||||
// Get status label
|
||||
const statusLabels: Record<ConnectionState, string> = {
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting...",
|
||||
connected: "Connected",
|
||||
error: "Error",
|
||||
disconnected: 'Disconnected',
|
||||
connecting: 'Connecting...',
|
||||
connected: 'Connected',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background/80 backdrop-blur-sm">
|
||||
<div className="max-w-md mx-auto border-b">
|
||||
{/* Status Bar - Always visible */}
|
||||
<button
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot state={connectionState} />
|
||||
<span className="text-sm font-medium">{statusLabels[connectionState]}</span>
|
||||
{isConnected && displayUrl && (
|
||||
<span className="text-xs text-muted-foreground">• {displayUrl}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
{/* Status Bar - Always visible */}
|
||||
<button
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusDot state={connectionState} />
|
||||
<span className="text-sm font-medium">{statusLabels[connectionState]}</span>
|
||||
{isConnected && displayUrl && <span className="text-xs text-muted-foreground">• {displayUrl}</span>}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${
|
||||
expanded ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted-foreground transition-transform duration-200 ${
|
||||
expanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Expandable Settings Panel */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-200 ease-out"
|
||||
style={{
|
||||
maxHeight: expanded ? maxHeight : 0,
|
||||
opacity: expanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef} className={`px-3 pb-3 pt-1 space-y-3 ${isShaking ? "animate-shake" : ""}`}>
|
||||
{/* Hidden file input for album scanning */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
{/* Expandable Settings Panel */}
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-200 ease-out"
|
||||
style={{
|
||||
maxHeight: expanded ? maxHeight : 0,
|
||||
opacity: expanded ? 1 : 0,
|
||||
}}
|
||||
>
|
||||
<div ref={contentRef} className={`px-3 pb-3 pt-1 space-y-3 ${isShaking ? 'animate-shake' : ''}`}>
|
||||
{/* Hidden file input for album scanning */}
|
||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFileSelect} className="hidden" />
|
||||
|
||||
{/* QR Scanner View - Portal to body to escape backdrop-blur containing block */}
|
||||
{isScanning && createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="flex-1 w-full object-cover"
|
||||
/>
|
||||
<Button
|
||||
onClick={stopScanning}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-4 right-4 h-10 w-10 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="absolute bottom-16 left-0 right-0 flex flex-col items-center gap-3">
|
||||
<Button
|
||||
onClick={handleSelectFromAlbum}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-9 px-4"
|
||||
>
|
||||
<Image className="h-4 w-4 mr-2" />
|
||||
Select from Album
|
||||
</Button>
|
||||
<span className="text-sm text-white/80">
|
||||
or point camera at QR code
|
||||
</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
{/* QR Scanner View - Portal to body to escape backdrop-blur containing block */}
|
||||
{isScanning &&
|
||||
createPortal(
|
||||
<div className="fixed inset-0 z-50 bg-black flex flex-col">
|
||||
<video ref={videoRef} className="flex-1 w-full object-cover" />
|
||||
<Button
|
||||
onClick={stopScanning}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-4 right-4 h-10 w-10 p-0 bg-black/50 hover:bg-black/70 text-white rounded-full"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="absolute bottom-16 left-0 right-0 flex flex-col items-center gap-3">
|
||||
<Button onClick={handleSelectFromAlbum} variant="secondary" size="sm" className="h-9 px-4">
|
||||
<Image className="h-4 w-4 mr-2" />
|
||||
Select from Album
|
||||
</Button>
|
||||
<span className="text-sm text-white/80">or point camera at QR code</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
|
||||
<div className={`space-y-3 ${isScanning ? "invisible" : ""}`}>
|
||||
{/* Connection Settings - use invisible (not hidden) to preserve scrollHeight for animation */}
|
||||
<div className={`space-y-3 ${isScanning ? 'invisible' : ''}`}>
|
||||
{/* Server URL */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="proxy-url">Server</Label>
|
||||
@@ -396,7 +378,7 @@ export function ACPConnect({
|
||||
<InputGroupInput
|
||||
id="proxy-url"
|
||||
value={settings.proxyUrl}
|
||||
onChange={(e) => updateSetting("proxyUrl", e.target.value)}
|
||||
onChange={e => updateSetting('proxyUrl', e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={isConnected || isConnecting}
|
||||
@@ -411,7 +393,7 @@ export function ACPConnect({
|
||||
className="h-9 px-4"
|
||||
type="button"
|
||||
>
|
||||
{isConnecting ? "..." : "Connect"}
|
||||
{isConnecting ? '...' : 'Connect'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -440,8 +422,8 @@ export function ACPConnect({
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="auth-token"
|
||||
value={settings.token || ""}
|
||||
onChange={(e) => updateSetting("token", e.target.value || undefined)}
|
||||
value={settings.token || ''}
|
||||
onChange={e => updateSetting('token', e.target.value || undefined)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="For remote access"
|
||||
disabled={isConnected || isConnecting}
|
||||
@@ -465,8 +447,8 @@ export function ACPConnect({
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
id="working-dir"
|
||||
value={settings.cwd || ""}
|
||||
onChange={(e) => updateSetting("cwd", e.target.value || undefined)}
|
||||
value={settings.cwd || ''}
|
||||
onChange={e => updateSetting('cwd', e.target.value || undefined)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="/path/to/project"
|
||||
disabled={isConnected || isConnecting}
|
||||
@@ -475,17 +457,13 @@ export function ACPConnect({
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && <div className="text-xs text-destructive bg-destructive/10 px-2 py-1.5 rounded">{error}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { AgentSessionInfo } from "../src/acp/types";
|
||||
import { ChatInterface } from "./ChatInterface";
|
||||
import { cn } from "../src/lib/utils";
|
||||
import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from "lucide-react";
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import type { ACPClient } from '../src/acp/client';
|
||||
import type { AgentSessionInfo } from '../src/acp/types';
|
||||
import { ChatInterface } from './ChatInterface';
|
||||
import { cn } from '../src/lib/utils';
|
||||
import { MessageSquare, Plus, PanelLeftClose, PanelLeft } from 'lucide-react';
|
||||
|
||||
interface ACPMainProps {
|
||||
client: ACPClient;
|
||||
@@ -18,35 +18,40 @@ export function ACPMain({ client, agentId }: ACPMainProps) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// Handle session selection
|
||||
const handleSelectSession = useCallback(async (session: AgentSessionInfo) => {
|
||||
try {
|
||||
if (client.supportsLoadSession) {
|
||||
await client.loadSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else if (client.supportsResumeSession) {
|
||||
await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else {
|
||||
throw new Error("Loading or resuming sessions is not supported by this agent.");
|
||||
const handleSelectSession = useCallback(
|
||||
async (session: AgentSessionInfo) => {
|
||||
try {
|
||||
if (client.supportsLoadSession) {
|
||||
await client.loadSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else if (client.supportsResumeSession) {
|
||||
await client.resumeSession({ sessionId: session.sessionId, cwd: session.cwd });
|
||||
} else {
|
||||
throw new Error('Loading or resuming sessions is not supported by this agent.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load/resume session:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load/resume session:", error);
|
||||
}
|
||||
}, [client]);
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 侧边栏 — Anthropic warm sidebar, hidden on mobile */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col border-r border-border/60 bg-surface-1/50 transition-all duration-200 flex-shrink-0",
|
||||
sidebarCollapsed ? "w-12" : "w-64",
|
||||
'hidden md:flex flex-col border-r border-border/60 bg-surface-1/50 transition-all duration-200 flex-shrink-0',
|
||||
sidebarCollapsed ? 'w-12' : 'w-64',
|
||||
)}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-4">
|
||||
{!sidebarCollapsed && (
|
||||
<span className="text-xs font-display font-semibold text-text-muted uppercase tracking-widest px-1">会话</span>
|
||||
<span className="text-xs font-display font-semibold text-text-muted uppercase tracking-widest px-1">
|
||||
会话
|
||||
</span>
|
||||
)}
|
||||
<div className={cn("flex items-center gap-0.5", sidebarCollapsed && "mx-auto")}>
|
||||
<div className={cn('flex items-center gap-0.5', sidebarCollapsed && 'mx-auto')}>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -64,11 +69,7 @@ export function ACPMain({ client, agentId }: ACPMainProps) {
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{sidebarCollapsed ? (
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
)}
|
||||
{sidebarCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,21 +115,21 @@ function SidebarSessionList({
|
||||
const response = await client.listSessions();
|
||||
setSessions(response.sessions);
|
||||
} catch (err) {
|
||||
console.warn("[SidebarSessionList] Failed to load:", err);
|
||||
console.warn('[SidebarSessionList] Failed to load:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (client.getState() === "connected" && client.supportsSessionList) {
|
||||
if (client.getState() === 'connected' && client.supportsSessionList) {
|
||||
loadSessions();
|
||||
}
|
||||
}, [client, loadSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (state: string) => {
|
||||
if (state === "connected") {
|
||||
if (state === 'connected') {
|
||||
setTimeout(loadSessions, 200);
|
||||
}
|
||||
};
|
||||
@@ -180,7 +181,7 @@ function SidebarSessionList({
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.sessions.map((session) => (
|
||||
{group.sessions.map(session => (
|
||||
<button
|
||||
key={session.sessionId}
|
||||
type="button"
|
||||
@@ -189,16 +190,16 @@ function SidebarSessionList({
|
||||
onSelectSession(session);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2.5 px-4 py-2 text-left transition-colors rounded-none",
|
||||
'w-full flex items-center gap-2.5 px-4 py-2 text-left transition-colors rounded-none',
|
||||
session.sessionId === activeId
|
||||
? "bg-brand/8 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-2/60 hover:text-text-primary",
|
||||
? 'bg-brand/8 text-text-primary'
|
||||
: 'text-text-secondary hover:bg-surface-2/60 hover:text-text-primary',
|
||||
)}
|
||||
title={session.title || session.sessionId}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 flex-shrink-0 opacity-50" />
|
||||
<span className="text-[13px] font-display truncate leading-snug">
|
||||
{session.title && session.title.trim() ? session.title : "新会话"}
|
||||
{session.title && session.title.trim() ? session.title : '新会话'}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -223,9 +224,9 @@ function groupByRecency(sessions: AgentSessionInfo[]): SessionGroup[] {
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
|
||||
const groups: SessionGroup[] = [
|
||||
{ label: "今天", sessions: [] },
|
||||
{ label: "昨天", sessions: [] },
|
||||
{ label: "更早", sessions: [] },
|
||||
{ label: '今天', sessions: [] },
|
||||
{ label: '昨天', sessions: [] },
|
||||
{ label: '更早', sessions: [] },
|
||||
];
|
||||
|
||||
for (const session of sessions) {
|
||||
@@ -239,5 +240,5 @@ function groupByRecency(sessions: AgentSessionInfo[]): SessionGroup[] {
|
||||
}
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.sessions.length > 0);
|
||||
return groups.filter(g => g.sessions.length > 0);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import imageCompression from "browser-image-compression";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { SessionUpdate, PermissionRequestPayload, PermissionOption, ContentBlock, ImageContent } from "../src/acp/types";
|
||||
import type { ThreadEntry, ToolCallStatus, ToolCallData, UserMessageImage, UserMessageEntry, AssistantMessageEntry, ToolCallEntry, ChatInputMessage, PendingPermission, PlanDisplayEntry } from "../src/lib/types";
|
||||
import { ChatView } from "./chat/ChatView";
|
||||
import { ChatInput } from "./chat/ChatInput";
|
||||
import { PermissionPanel } from "./chat/PermissionPanel";
|
||||
import { ModelSelectorPopover } from "./model-selector";
|
||||
import { useCommands } from "../src/hooks/useCommands";
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
import type { ACPClient } from '../src/acp/client';
|
||||
import type {
|
||||
SessionUpdate,
|
||||
PermissionRequestPayload,
|
||||
PermissionOption,
|
||||
ContentBlock,
|
||||
ImageContent,
|
||||
} from '../src/acp/types';
|
||||
import type {
|
||||
ThreadEntry,
|
||||
ToolCallStatus,
|
||||
ToolCallData,
|
||||
UserMessageImage,
|
||||
UserMessageEntry,
|
||||
AssistantMessageEntry,
|
||||
ToolCallEntry,
|
||||
ChatInputMessage,
|
||||
PendingPermission,
|
||||
PlanDisplayEntry,
|
||||
} from '../src/lib/types';
|
||||
import { ChatView } from './chat/ChatView';
|
||||
import { ChatInput } from './chat/ChatInput';
|
||||
import { PermissionPanel } from './chat/PermissionPanel';
|
||||
import { ModelSelectorPopover } from './model-selector';
|
||||
import { useCommands } from '../src/hooks/useCommands';
|
||||
|
||||
// Image compression options
|
||||
// Claude API has a 5MB limit, so we target 2MB to be safe
|
||||
const IMAGE_COMPRESSION_OPTIONS = {
|
||||
maxSizeMB: 2, // Max output size in MB
|
||||
maxSizeMB: 2, // Max output size in MB
|
||||
maxWidthOrHeight: 2048, // Max dimension (scales proportionally, no cropping)
|
||||
useWebWorker: true, // Non-blocking compression
|
||||
fileType: "image/jpeg" as const, // Convert to JPEG for better compression
|
||||
useWebWorker: true, // Non-blocking compression
|
||||
fileType: 'image/jpeg' as const, // Convert to JPEG for better compression
|
||||
};
|
||||
|
||||
// Convert data URL to Blob without using fetch()
|
||||
// This is critical for Chrome extensions where fetch(dataUrl) violates CSP
|
||||
function dataUrlToBlob(dataUrl: string): Blob {
|
||||
// Parse the data URL: data:[<mediatype>][;base64],<data>
|
||||
const commaIndex = dataUrl.indexOf(",");
|
||||
const commaIndex = dataUrl.indexOf(',');
|
||||
if (commaIndex === -1) {
|
||||
throw new Error("Invalid data URL: missing comma separator");
|
||||
throw new Error('Invalid data URL: missing comma separator');
|
||||
}
|
||||
|
||||
const header = dataUrl.slice(0, commaIndex);
|
||||
@@ -32,7 +49,7 @@ function dataUrlToBlob(dataUrl: string): Blob {
|
||||
|
||||
// Extract MIME type from header (e.g., "data:image/png;base64")
|
||||
const mimeMatch = header.match(/^data:([^;,]+)/);
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : "application/octet-stream";
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
|
||||
|
||||
// Decode base64 to binary
|
||||
const binaryString = atob(base64Data);
|
||||
@@ -44,14 +61,10 @@ function dataUrlToBlob(dataUrl: string): Blob {
|
||||
return new Blob([bytes], { type: mimeType });
|
||||
}
|
||||
|
||||
import { Plus, Shield, ChevronDown, ChevronUp, Check } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { Plus, Shield, ChevronDown, ChevronUp, Check } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions - imported from shared types module
|
||||
@@ -67,43 +80,29 @@ interface ChatInterfaceProps {
|
||||
// =============================================================================
|
||||
|
||||
const PERMISSION_MODES = [
|
||||
{ value: "default", label: "默认", description: "手动审批权限请求" },
|
||||
{ value: "acceptEdits", label: "自动接受编辑", description: "自动允许文件编辑操作" },
|
||||
{ value: "bypassPermissions", label: "跳过权限", description: "跳过所有权限检查" },
|
||||
{ value: "plan", label: "规划模式", description: "仅规划,不执行工具" },
|
||||
{ value: "dontAsk", label: "不询问", description: "不弹出询问,自动拒绝" },
|
||||
{ value: "auto", label: "自动判断", description: "AI 自动判断是否批准" },
|
||||
{ value: 'default', label: '默认', description: '手动审批权限请求' },
|
||||
{ value: 'acceptEdits', label: '自动接受编辑', description: '自动允许文件编辑操作' },
|
||||
{ value: 'bypassPermissions', label: '跳过权限', description: '跳过所有权限检查' },
|
||||
{ value: 'plan', label: '规划模式', description: '仅规划,不执行工具' },
|
||||
{ value: 'dontAsk', label: '不询问', description: '不弹出询问,自动拒绝' },
|
||||
{ value: 'auto', label: '自动判断', description: 'AI 自动判断是否批准' },
|
||||
] as const;
|
||||
|
||||
function PermissionModeSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: string;
|
||||
onModeChange: (mode: string) => void;
|
||||
}) {
|
||||
function PermissionModeSelector({ mode, onModeChange }: { mode: string; onModeChange: (mode: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const current = PERMISSION_MODES.find((m) => m.value === mode) ?? PERMISSION_MODES[0];
|
||||
const current = PERMISSION_MODES.find(m => m.value === mode) ?? PERMISSION_MODES[0];
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span className="max-w-24 truncate">{current.label}</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
{open ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-1" align="start">
|
||||
{PERMISSION_MODES.map((m) => (
|
||||
{PERMISSION_MODES.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
type="button"
|
||||
@@ -133,16 +132,16 @@ function PermissionModeSelector({
|
||||
|
||||
// Map ACP status string to our status type
|
||||
function mapToolStatus(status: string): ToolCallStatus {
|
||||
if (status === "completed") return "complete";
|
||||
if (status === "failed") return "error";
|
||||
return "running";
|
||||
if (status === 'completed') return 'complete';
|
||||
if (status === 'failed') return 'error';
|
||||
return 'running';
|
||||
}
|
||||
|
||||
// Find tool call index in entries (search from end, like Zed)
|
||||
function findToolCallIndex(entries: ThreadEntry[], toolCallId: string): number {
|
||||
for (let i = entries.length - 1; i >= 0; i--) {
|
||||
const entry = entries[i];
|
||||
if (entry && entry.type === "tool_call" && entry.toolCall.id === toolCallId) {
|
||||
if (entry && entry.type === 'tool_call' && entry.toolCall.id === toolCallId) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -162,7 +161,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
const activeSessionIdRef = useRef<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const errorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [permissionMode, setPermissionMode] = useState(() => localStorage.getItem("acp_permission_mode") || "default");
|
||||
const [permissionMode, setPermissionMode] = useState(() => localStorage.getItem('acp_permission_mode') || 'default');
|
||||
// Reference: Zed's supports_images() checks prompt_capabilities.image
|
||||
const [supportsImages, setSupportsImages] = useState(false);
|
||||
const { commands: availableCommands } = useCommands(client);
|
||||
@@ -179,21 +178,26 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
|
||||
const storageKey = agentId ? `acp_last_session_${agentId}` : null;
|
||||
|
||||
const activateSession = useCallback((sessionId: string, options?: { resetEntries?: boolean }) => {
|
||||
const shouldResetEntries = options?.resetEntries ?? true;
|
||||
if (shouldResetEntries) {
|
||||
setEntries([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
setActiveSessionId(sessionId);
|
||||
setSessionReady(true);
|
||||
setSupportsImages(client.supportsImages);
|
||||
// Persist session ID for restoration on remount
|
||||
if (storageKey) {
|
||||
try { localStorage.setItem(storageKey, sessionId); } catch {}
|
||||
}
|
||||
console.log("[ChatInterface] Active session:", sessionId, "supportsImages:", client.supportsImages);
|
||||
}, [client, storageKey]);
|
||||
const activateSession = useCallback(
|
||||
(sessionId: string, options?: { resetEntries?: boolean }) => {
|
||||
const shouldResetEntries = options?.resetEntries ?? true;
|
||||
if (shouldResetEntries) {
|
||||
setEntries([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
setActiveSessionId(sessionId);
|
||||
setSessionReady(true);
|
||||
setSupportsImages(client.supportsImages);
|
||||
// Persist session ID for restoration on remount
|
||||
if (storageKey) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, sessionId);
|
||||
} catch {}
|
||||
}
|
||||
console.log('[ChatInterface] Active session:', sessionId, 'supportsImages:', client.supportsImages);
|
||||
},
|
||||
[client, storageKey],
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Permission Request Handler
|
||||
@@ -202,9 +206,9 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
if (activeSessionIdRef.current && request.sessionId !== activeSessionIdRef.current) {
|
||||
return;
|
||||
}
|
||||
console.log("[ChatInterface] Permission request:", request);
|
||||
console.log('[ChatInterface] Permission request:', request);
|
||||
|
||||
setEntries((prev) => {
|
||||
setEntries(prev => {
|
||||
// Find matching tool call (search from end)
|
||||
const toolCallIndex = findToolCallIndex(prev, request.toolCall.toolCallId);
|
||||
|
||||
@@ -212,14 +216,14 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// Update existing tool call's status
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== toolCallIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.toolCall.status !== "running") return entry;
|
||||
if (entry.type !== 'tool_call') return entry;
|
||||
if (entry.toolCall.status !== 'running') return entry;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: "waiting_for_confirmation" as const,
|
||||
status: 'waiting_for_confirmation' as const,
|
||||
permissionRequest: {
|
||||
requestId: request.requestId,
|
||||
options: request.options,
|
||||
@@ -229,14 +233,14 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
});
|
||||
} else {
|
||||
// No matching tool call - create standalone permission request as new entry
|
||||
console.log("[ChatInterface] No matching tool call, creating standalone permission request");
|
||||
console.log('[ChatInterface] No matching tool call, creating standalone permission request');
|
||||
|
||||
const permissionToolCall: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: request.toolCall.toolCallId,
|
||||
title: request.toolCall.title || "Permission Request",
|
||||
status: "waiting_for_confirmation",
|
||||
title: request.toolCall.title || 'Permission Request',
|
||||
status: 'waiting_for_confirmation',
|
||||
permissionRequest: {
|
||||
requestId: request.requestId,
|
||||
options: request.options,
|
||||
@@ -259,27 +263,24 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
}
|
||||
|
||||
// Handle agent message chunk
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
const text = update.content.type === 'text' && update.content.text ? update.content.text : '';
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
setEntries(prev => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is AssistantMessage, append to it
|
||||
if (lastEntry?.type === "assistant_message") {
|
||||
if (lastEntry?.type === 'assistant_message') {
|
||||
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
||||
|
||||
// If last chunk is same type (message), append text
|
||||
if (lastChunk?.type === "message") {
|
||||
if (lastChunk?.type === 'message') {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [
|
||||
...lastEntry.chunks.slice(0, -1),
|
||||
{ type: "message", text: lastChunk.text + text },
|
||||
],
|
||||
chunks: [...lastEntry.chunks.slice(0, -1), { type: 'message', text: lastChunk.text + text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -289,42 +290,39 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [...lastEntry.chunks, { type: "message", text }],
|
||||
chunks: [...lastEntry.chunks, { type: 'message', text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new AssistantMessage entry
|
||||
const newEntry: AssistantMessageEntry = {
|
||||
type: "assistant_message",
|
||||
type: 'assistant_message',
|
||||
id: `assistant-${Date.now()}`,
|
||||
chunks: [{ type: "message", text }],
|
||||
chunks: [{ type: 'message', text }],
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle agent thought chunk (NEW - was missing before)
|
||||
else if (update.sessionUpdate === "agent_thought_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
else if (update.sessionUpdate === 'agent_thought_chunk') {
|
||||
const text = update.content.type === 'text' && update.content.text ? update.content.text : '';
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
setEntries(prev => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is AssistantMessage, append to it
|
||||
if (lastEntry?.type === "assistant_message") {
|
||||
if (lastEntry?.type === 'assistant_message') {
|
||||
const lastChunk = lastEntry.chunks[lastEntry.chunks.length - 1];
|
||||
|
||||
// If last chunk is same type (thought), append text
|
||||
if (lastChunk?.type === "thought") {
|
||||
if (lastChunk?.type === 'thought') {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [
|
||||
...lastEntry.chunks.slice(0, -1),
|
||||
{ type: "thought", text: lastChunk.text + text },
|
||||
],
|
||||
chunks: [...lastEntry.chunks.slice(0, -1), { type: 'thought', text: lastChunk.text + text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -334,30 +332,30 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...lastEntry,
|
||||
chunks: [...lastEntry.chunks, { type: "thought", text }],
|
||||
chunks: [...lastEntry.chunks, { type: 'thought', text }],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Create new AssistantMessage entry with thought
|
||||
const newEntry: AssistantMessageEntry = {
|
||||
type: "assistant_message",
|
||||
type: 'assistant_message',
|
||||
id: `assistant-${Date.now()}`,
|
||||
chunks: [{ type: "thought", text }],
|
||||
chunks: [{ type: 'thought', text }],
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle user message chunk (NEW - was missing before)
|
||||
else if (update.sessionUpdate === "user_message_chunk") {
|
||||
const text = update.content.type === "text" && update.content.text ? update.content.text : "";
|
||||
else if (update.sessionUpdate === 'user_message_chunk') {
|
||||
const text = update.content.type === 'text' && update.content.text ? update.content.text : '';
|
||||
if (!text) return;
|
||||
|
||||
setEntries((prev) => {
|
||||
setEntries(prev => {
|
||||
const lastEntry = prev[prev.length - 1];
|
||||
|
||||
// If last entry is UserMessage, append to it
|
||||
if (lastEntry?.type === "user_message") {
|
||||
if (lastEntry?.type === 'user_message') {
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
@@ -369,7 +367,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
|
||||
// Create new UserMessage entry
|
||||
const newEntry: UserMessageEntry = {
|
||||
type: "user_message",
|
||||
type: 'user_message',
|
||||
id: `user-${Date.now()}`,
|
||||
content: text,
|
||||
};
|
||||
@@ -377,7 +375,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
});
|
||||
}
|
||||
// Handle tool call (UPSERT - update if exists, create if not)
|
||||
else if (update.sessionUpdate === "tool_call") {
|
||||
else if (update.sessionUpdate === 'tool_call') {
|
||||
const toolCallData: ToolCallData = {
|
||||
id: update.toolCallId,
|
||||
title: update.title,
|
||||
@@ -387,7 +385,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
rawOutput: update.rawOutput,
|
||||
};
|
||||
|
||||
setEntries((prev) => {
|
||||
setEntries(prev => {
|
||||
// UPSERT: Check if tool call already exists
|
||||
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
||||
|
||||
@@ -395,10 +393,10 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// UPDATE existing tool call
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== existingIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.type !== 'tool_call') return entry;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
...toolCallData,
|
||||
@@ -409,27 +407,27 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
|
||||
// CREATE new tool call entry
|
||||
const newEntry: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
type: 'tool_call',
|
||||
toolCall: toolCallData,
|
||||
};
|
||||
return [...prev, newEntry];
|
||||
});
|
||||
}
|
||||
// Handle tool call update (partial update)
|
||||
else if (update.sessionUpdate === "tool_call_update") {
|
||||
setEntries((prev) => {
|
||||
else if (update.sessionUpdate === 'tool_call_update') {
|
||||
setEntries(prev => {
|
||||
const existingIndex = findToolCallIndex(prev, update.toolCallId);
|
||||
|
||||
if (existingIndex < 0) {
|
||||
// Tool call not found - create a failed tool call entry (like Zed)
|
||||
console.warn(`[ChatInterface] Tool call not found for update: ${update.toolCallId}`);
|
||||
const failedEntry: ToolCallEntry = {
|
||||
type: "tool_call",
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
id: update.toolCallId,
|
||||
title: update.title || "Tool call not found",
|
||||
status: "error",
|
||||
content: [{ type: "content", content: { type: "text", text: "Tool call not found" } }],
|
||||
title: update.title || 'Tool call not found',
|
||||
status: 'error',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'Tool call not found' } }],
|
||||
},
|
||||
};
|
||||
return [...prev, failedEntry];
|
||||
@@ -437,7 +435,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
|
||||
return prev.map((entry, index) => {
|
||||
if (index !== existingIndex) return entry;
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.type !== 'tool_call') return entry;
|
||||
|
||||
const newStatus = update.status ? mapToolStatus(update.status) : entry.toolCall.status;
|
||||
const mergedContent = update.content
|
||||
@@ -445,7 +443,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
: entry.toolCall.content;
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: newStatus,
|
||||
@@ -459,31 +457,24 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
});
|
||||
}
|
||||
// Handle plan update (replace entire plan)
|
||||
else if (update.sessionUpdate === "plan") {
|
||||
setEntries((prev) => {
|
||||
else if (update.sessionUpdate === 'plan') {
|
||||
setEntries(prev => {
|
||||
// Empty entries → remove existing plan
|
||||
if (update.entries.length === 0) {
|
||||
return prev.filter((e) => e.type !== "plan");
|
||||
return prev.filter(e => e.type !== 'plan');
|
||||
}
|
||||
|
||||
// Find last plan entry
|
||||
const lastPlanIndex = prev.reduce(
|
||||
(acc, entry, i) => (entry.type === "plan" ? i : acc),
|
||||
-1,
|
||||
);
|
||||
const lastPlanIndex = prev.reduce((acc, entry, i) => (entry.type === 'plan' ? i : acc), -1);
|
||||
|
||||
if (lastPlanIndex >= 0) {
|
||||
// Update existing plan in place
|
||||
return prev.map((entry, index) =>
|
||||
index === lastPlanIndex
|
||||
? { ...entry, entries: update.entries }
|
||||
: entry,
|
||||
);
|
||||
return prev.map((entry, index) => (index === lastPlanIndex ? { ...entry, entries: update.entries } : entry));
|
||||
}
|
||||
|
||||
// Create new plan entry
|
||||
const newPlanEntry: PlanDisplayEntry = {
|
||||
type: "plan",
|
||||
type: 'plan',
|
||||
id: `plan-${Date.now()}`,
|
||||
entries: update.entries,
|
||||
};
|
||||
@@ -496,18 +487,18 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// Setup Effect
|
||||
// =============================================================================
|
||||
useEffect(() => {
|
||||
client.setSessionCreatedHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Session created:", sessionId);
|
||||
client.setSessionCreatedHandler(sessionId => {
|
||||
console.log('[ChatInterface] Session created:', sessionId);
|
||||
activateSession(sessionId);
|
||||
});
|
||||
|
||||
client.setSessionLoadedHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Session loaded/resumed:", sessionId);
|
||||
client.setSessionLoadedHandler(sessionId => {
|
||||
console.log('[ChatInterface] Session loaded/resumed:', sessionId);
|
||||
activateSession(sessionId, { resetEntries: false });
|
||||
});
|
||||
|
||||
client.setSessionSwitchingHandler((sessionId) => {
|
||||
console.log("[ChatInterface] Switching to session:", sessionId);
|
||||
client.setSessionSwitchingHandler(sessionId => {
|
||||
console.log('[ChatInterface] Switching to session:', sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
resetThreadState();
|
||||
});
|
||||
@@ -516,8 +507,8 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
handleSessionUpdate(sessionId, update);
|
||||
});
|
||||
|
||||
client.setPromptCompleteHandler((stopReason) => {
|
||||
console.log("[ChatInterface] Prompt complete:", stopReason);
|
||||
client.setPromptCompleteHandler(stopReason => {
|
||||
console.log('[ChatInterface] Prompt complete:', stopReason);
|
||||
// Always set isLoading=false when prompt completes
|
||||
// This includes stopReason="cancelled" (which is the expected response after client.cancel())
|
||||
// Note: Tool calls are already marked as "canceled" in handleCancel before this fires
|
||||
@@ -526,8 +517,8 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
|
||||
client.setPermissionRequestHandler(handlePermissionRequest);
|
||||
|
||||
client.setErrorMessageHandler((msg) => {
|
||||
console.error("[ChatInterface] Agent error:", msg);
|
||||
client.setErrorMessageHandler(msg => {
|
||||
console.error('[ChatInterface] Agent error:', msg);
|
||||
setErrorMessage(msg);
|
||||
// Clear any existing timer
|
||||
if (errorTimerRef.current) clearTimeout(errorTimerRef.current);
|
||||
@@ -538,7 +529,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// Restore last session or create a new one
|
||||
const lastSessionId = storageKey ? localStorage.getItem(storageKey) : null;
|
||||
if (lastSessionId && (client.supportsLoadSession || client.supportsResumeSession)) {
|
||||
console.log("[ChatInterface] Restoring session:", lastSessionId);
|
||||
console.log('[ChatInterface] Restoring session:', lastSessionId);
|
||||
const restore = async () => {
|
||||
try {
|
||||
if (client.supportsLoadSession) {
|
||||
@@ -547,7 +538,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
await client.resumeSession({ sessionId: lastSessionId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[ChatInterface] Failed to restore session, creating new one:", err);
|
||||
console.warn('[ChatInterface] Failed to restore session, creating new one:', err);
|
||||
client.createSession(undefined, permissionMode);
|
||||
}
|
||||
};
|
||||
@@ -575,7 +566,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// Creates a new session by clearing current state and calling new_session
|
||||
// This is the core of Zed's NewThread action
|
||||
const handleNewSession = useCallback(() => {
|
||||
console.log("[ChatInterface] Creating new session...");
|
||||
console.log('[ChatInterface] Creating new session...');
|
||||
|
||||
// Reference: Zed's set_server_state() calls close_all_sessions() before setting new state
|
||||
// Cancel any ongoing request before creating new session
|
||||
@@ -597,26 +588,25 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// 2. Send cancel notification to agent
|
||||
// 3. Do NOT set isLoading=false here - wait for prompt_complete with stopReason="cancelled"
|
||||
const handleCancel = () => {
|
||||
console.log("[ChatInterface] Cancel requested");
|
||||
console.log('[ChatInterface] Cancel requested');
|
||||
|
||||
// Like Zed: iterate all entries, mark Pending/WaitingForConfirmation/InProgress tool calls as Canceled
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
setEntries(prev =>
|
||||
prev.map(entry => {
|
||||
if (entry.type !== 'tool_call') return entry;
|
||||
|
||||
// Check if status should be canceled (matches Zed's logic)
|
||||
const shouldCancel =
|
||||
entry.toolCall.status === "running" ||
|
||||
entry.toolCall.status === "waiting_for_confirmation";
|
||||
entry.toolCall.status === 'running' || entry.toolCall.status === 'waiting_for_confirmation';
|
||||
|
||||
if (!shouldCancel) return entry;
|
||||
|
||||
console.log("[ChatInterface] Marking tool call as canceled:", entry.toolCall.id);
|
||||
console.log('[ChatInterface] Marking tool call as canceled:', entry.toolCall.id);
|
||||
return {
|
||||
type: "tool_call",
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: "canceled" as ToolCallStatus,
|
||||
status: 'canceled' as ToolCallStatus,
|
||||
permissionRequest: undefined, // Clear any pending permission request
|
||||
},
|
||||
};
|
||||
@@ -629,42 +619,45 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
// Wait for prompt_complete with stopReason="cancelled" from the agent
|
||||
};
|
||||
|
||||
const handlePermissionResponse = useCallback((requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => {
|
||||
console.log("[ChatInterface] Permission response:", { requestId, optionId, optionKind });
|
||||
client.respondToPermission(requestId, optionId);
|
||||
const handlePermissionResponse = useCallback(
|
||||
(requestId: string, optionId: string | null, optionKind: PermissionOption['kind'] | null) => {
|
||||
console.log('[ChatInterface] Permission response:', { requestId, optionId, optionKind });
|
||||
client.respondToPermission(requestId, optionId);
|
||||
|
||||
// Determine new status based on option kind
|
||||
const isRejected = optionKind === "reject_once" || optionKind === "reject_always" || optionId === null;
|
||||
// Determine new status based on option kind
|
||||
const isRejected = optionKind === 'reject_once' || optionKind === 'reject_always' || optionId === null;
|
||||
|
||||
// Update the tool call status in entries
|
||||
setEntries((prev) =>
|
||||
prev.map((entry) => {
|
||||
if (entry.type !== "tool_call") return entry;
|
||||
if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry;
|
||||
// Update the tool call status in entries
|
||||
setEntries(prev =>
|
||||
prev.map(entry => {
|
||||
if (entry.type !== 'tool_call') return entry;
|
||||
if (entry.toolCall.permissionRequest?.requestId !== requestId) return entry;
|
||||
|
||||
// For standalone permission requests, mark as complete immediately when approved
|
||||
// For regular tool calls, mark as running (agent will update to complete later)
|
||||
let newStatus: ToolCallStatus;
|
||||
if (isRejected) {
|
||||
newStatus = "rejected";
|
||||
} else if (entry.toolCall.isStandalonePermission) {
|
||||
newStatus = "complete";
|
||||
} else {
|
||||
newStatus = "running";
|
||||
}
|
||||
// For standalone permission requests, mark as complete immediately when approved
|
||||
// For regular tool calls, mark as running (agent will update to complete later)
|
||||
let newStatus: ToolCallStatus;
|
||||
if (isRejected) {
|
||||
newStatus = 'rejected';
|
||||
} else if (entry.toolCall.isStandalonePermission) {
|
||||
newStatus = 'complete';
|
||||
} else {
|
||||
newStatus = 'running';
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tool_call",
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: newStatus,
|
||||
permissionRequest: undefined,
|
||||
isStandalonePermission: undefined,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}, [client]);
|
||||
return {
|
||||
type: 'tool_call',
|
||||
toolCall: {
|
||||
...entry.toolCall,
|
||||
status: newStatus,
|
||||
permissionRequest: undefined,
|
||||
isStandalonePermission: undefined,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
},
|
||||
[client],
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Render
|
||||
@@ -672,8 +665,11 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
|
||||
// Collect pending permissions from tool call entries
|
||||
const pendingPermissions: PendingPermission[] = entries
|
||||
.filter((e): e is ToolCallEntry => e.type === "tool_call" && e.toolCall.status === "waiting_for_confirmation" && !!e.toolCall.permissionRequest)
|
||||
.map((e) => ({
|
||||
.filter(
|
||||
(e): e is ToolCallEntry =>
|
||||
e.type === 'tool_call' && e.toolCall.status === 'waiting_for_confirmation' && !!e.toolCall.permissionRequest,
|
||||
)
|
||||
.map(e => ({
|
||||
requestId: e.toolCall.permissionRequest!.requestId,
|
||||
toolName: e.toolCall.title,
|
||||
toolInput: e.toolCall.rawInput || {},
|
||||
@@ -682,120 +678,128 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
}));
|
||||
|
||||
// Handle permission respond for unified PermissionPanel
|
||||
const handlePermissionPanelRespond = useCallback((requestId: string, approved: boolean) => {
|
||||
// Find the matching permission request to get the real optionId
|
||||
const perm = pendingPermissions.find((p) => p.requestId === requestId);
|
||||
let optionId: string | null = null;
|
||||
let optionKind: PermissionOption["kind"] | null = null;
|
||||
const handlePermissionPanelRespond = useCallback(
|
||||
(requestId: string, approved: boolean) => {
|
||||
// Find the matching permission request to get the real optionId
|
||||
const perm = pendingPermissions.find(p => p.requestId === requestId);
|
||||
let optionId: string | null = null;
|
||||
let optionKind: PermissionOption['kind'] | null = null;
|
||||
|
||||
if (perm?.options && perm.options.length > 0) {
|
||||
if (approved) {
|
||||
// Pick the first allow option (prefer allow_once, then allow_always)
|
||||
const allowOpt = perm.options.find((o) => o.kind === "allow_once") ?? perm.options.find((o) => o.kind === "allow_always");
|
||||
if (allowOpt) {
|
||||
optionId = allowOpt.optionId;
|
||||
optionKind = allowOpt.kind;
|
||||
}
|
||||
} else {
|
||||
// Pick the first reject option
|
||||
const rejectOpt = perm.options.find((o) => o.kind === "reject_once") ?? perm.options.find((o) => o.kind === "reject_always");
|
||||
if (rejectOpt) {
|
||||
optionId = rejectOpt.optionId;
|
||||
optionKind = rejectOpt.kind;
|
||||
if (perm?.options && perm.options.length > 0) {
|
||||
if (approved) {
|
||||
// Pick the first allow option (prefer allow_once, then allow_always)
|
||||
const allowOpt =
|
||||
perm.options.find(o => o.kind === 'allow_once') ?? perm.options.find(o => o.kind === 'allow_always');
|
||||
if (allowOpt) {
|
||||
optionId = allowOpt.optionId;
|
||||
optionKind = allowOpt.kind;
|
||||
}
|
||||
} else {
|
||||
// Pick the first reject option
|
||||
const rejectOpt =
|
||||
perm.options.find(o => o.kind === 'reject_once') ?? perm.options.find(o => o.kind === 'reject_always');
|
||||
if (rejectOpt) {
|
||||
optionId = rejectOpt.optionId;
|
||||
optionKind = rejectOpt.kind;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no matching option found, use null (cancelled)
|
||||
if (!optionId) {
|
||||
optionKind = approved ? "allow_once" : "reject_once";
|
||||
}
|
||||
// Fallback: if no matching option found, use null (cancelled)
|
||||
if (!optionId) {
|
||||
optionKind = approved ? 'allow_once' : 'reject_once';
|
||||
}
|
||||
|
||||
handlePermissionResponse(requestId, optionId, optionKind);
|
||||
}, [handlePermissionResponse, pendingPermissions]);
|
||||
handlePermissionResponse(requestId, optionId, optionKind);
|
||||
},
|
||||
[handlePermissionResponse, pendingPermissions],
|
||||
);
|
||||
|
||||
// Handle ChatInput submit — convert ChatInputMessage to ContentBlock[]
|
||||
const handleChatInputSubmit = useCallback(async (message: ChatInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
const images = message.images || [];
|
||||
const handleChatInputSubmit = useCallback(
|
||||
async (message: ChatInputMessage) => {
|
||||
const text = message.text.trim();
|
||||
const images = message.images || [];
|
||||
|
||||
if ((!text && images.length === 0) || isLoading || !sessionReady) return;
|
||||
if ((!text && images.length === 0) || isLoading || !sessionReady) return;
|
||||
|
||||
const contentBlocks: ContentBlock[] = [];
|
||||
const contentBlocks: ContentBlock[] = [];
|
||||
|
||||
if (text) {
|
||||
contentBlocks.push({ type: "text", text });
|
||||
}
|
||||
|
||||
// Convert images to ContentBlock
|
||||
const userImages: UserMessageImage[] = [];
|
||||
|
||||
for (const img of images) {
|
||||
try {
|
||||
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
|
||||
let blob: Blob;
|
||||
if (dataUrl.startsWith("data:")) {
|
||||
blob = dataUrlToBlob(dataUrl);
|
||||
} else {
|
||||
const response = await fetch(dataUrl);
|
||||
blob = await response.blob();
|
||||
}
|
||||
|
||||
let finalBlob: Blob = blob;
|
||||
let finalMimeType = img.mimeType;
|
||||
|
||||
if (blob.size > 2 * 1024 * 1024) {
|
||||
const imageFile = new File([blob], "image.jpg", { type: blob.type });
|
||||
finalBlob = await imageCompression(imageFile, IMAGE_COMPRESSION_OPTIONS);
|
||||
finalMimeType = "image/jpeg";
|
||||
}
|
||||
|
||||
const base64Data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIndex = result.indexOf(",");
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("FileReader error: " + reader.error?.message));
|
||||
reader.readAsDataURL(finalBlob);
|
||||
});
|
||||
|
||||
const imageContent: ImageContent = {
|
||||
type: "image",
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
};
|
||||
contentBlocks.push(imageContent);
|
||||
|
||||
userImages.push({
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[ChatInterface] Failed to process image:", error);
|
||||
if (text) {
|
||||
contentBlocks.push({ type: 'text', text });
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length === 0) return;
|
||||
// Convert images to ContentBlock
|
||||
const userImages: UserMessageImage[] = [];
|
||||
|
||||
// Add user message entry
|
||||
const userEntry: UserMessageEntry = {
|
||||
type: "user_message",
|
||||
id: `user-${Date.now()}`,
|
||||
content: text,
|
||||
images: userImages.length > 0 ? userImages : undefined,
|
||||
};
|
||||
setEntries((prev) => [...prev, userEntry]);
|
||||
setIsLoading(true);
|
||||
for (const img of images) {
|
||||
try {
|
||||
const dataUrl = `data:${img.mimeType};base64,${img.data}`;
|
||||
let blob: Blob;
|
||||
if (dataUrl.startsWith('data:')) {
|
||||
blob = dataUrlToBlob(dataUrl);
|
||||
} else {
|
||||
const response = await fetch(dataUrl);
|
||||
blob = await response.blob();
|
||||
}
|
||||
|
||||
try {
|
||||
await client.sendPrompt(contentBlocks);
|
||||
} catch (error) {
|
||||
console.error("[ChatInterface] Failed to send prompt:", error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, sessionReady, client]);
|
||||
let finalBlob: Blob = blob;
|
||||
let finalMimeType = img.mimeType;
|
||||
|
||||
if (blob.size > 2 * 1024 * 1024) {
|
||||
const imageFile = new File([blob], 'image.jpg', { type: blob.type });
|
||||
finalBlob = await imageCompression(imageFile, IMAGE_COMPRESSION_OPTIONS);
|
||||
finalMimeType = 'image/jpeg';
|
||||
}
|
||||
|
||||
const base64Data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIndex = result.indexOf(',');
|
||||
resolve(commaIndex >= 0 ? result.slice(commaIndex + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error('FileReader error: ' + reader.error?.message));
|
||||
reader.readAsDataURL(finalBlob);
|
||||
});
|
||||
|
||||
const imageContent: ImageContent = {
|
||||
type: 'image',
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
};
|
||||
contentBlocks.push(imageContent);
|
||||
|
||||
userImages.push({
|
||||
mimeType: finalMimeType,
|
||||
data: base64Data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[ChatInterface] Failed to process image:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (contentBlocks.length === 0) return;
|
||||
|
||||
// Add user message entry
|
||||
const userEntry: UserMessageEntry = {
|
||||
type: 'user_message',
|
||||
id: `user-${Date.now()}`,
|
||||
content: text,
|
||||
images: userImages.length > 0 ? userImages : undefined,
|
||||
};
|
||||
setEntries(prev => [...prev, userEntry]);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await client.sendPrompt(contentBlocks);
|
||||
} catch (error) {
|
||||
console.error('[ChatInterface] Failed to send prompt:', error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, sessionReady, client],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -804,17 +808,14 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
entries={entries}
|
||||
isLoading={isLoading && !sessionReady ? false : isLoading}
|
||||
onPermissionRespond={(requestId, optionId, optionKind) => {
|
||||
handlePermissionResponse(requestId, optionId, optionKind as PermissionOption["kind"] | null);
|
||||
handlePermissionResponse(requestId, optionId, optionKind as PermissionOption['kind'] | null);
|
||||
}}
|
||||
emptyTitle={sessionReady ? "开始对话" : undefined}
|
||||
emptyDescription={sessionReady ? "输入消息开始与 ACP agent 聊天" : undefined}
|
||||
emptyTitle={sessionReady ? '开始对话' : undefined}
|
||||
emptyDescription={sessionReady ? '输入消息开始与 ACP agent 聊天' : undefined}
|
||||
/>
|
||||
|
||||
{/* Permission panel — fixed above input */}
|
||||
<PermissionPanel
|
||||
requests={pendingPermissions}
|
||||
onRespond={handlePermissionPanelRespond}
|
||||
/>
|
||||
<PermissionPanel requests={pendingPermissions} onRespond={handlePermissionPanelRespond} />
|
||||
|
||||
{/* Error banner */}
|
||||
{errorMessage && (
|
||||
@@ -826,7 +827,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
onClick={() => setErrorMessage(null)}
|
||||
className="ml-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 flex-shrink-0"
|
||||
>
|
||||
{"\u00D7"}
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -836,7 +837,13 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
<div className="flex-shrink-0">
|
||||
<div className="max-w-3xl mx-auto w-full px-4 sm:px-8 pb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<PermissionModeSelector mode={permissionMode} onModeChange={(m: string) => { setPermissionMode(m); localStorage.setItem("acp_permission_mode", m); }} />
|
||||
<PermissionModeSelector
|
||||
mode={permissionMode}
|
||||
onModeChange={(m: string) => {
|
||||
setPermissionMode(m);
|
||||
localStorage.setItem('acp_permission_mode', m);
|
||||
}}
|
||||
/>
|
||||
<ModelSelectorPopover client={client} />
|
||||
</div>
|
||||
{entries.length > 0 && (
|
||||
@@ -861,7 +868,7 @@ export function ChatInterface({ client, agentId }: ChatInterfaceProps) {
|
||||
isLoading={isLoading}
|
||||
onInterrupt={handleCancel}
|
||||
disabled={!sessionReady}
|
||||
placeholder={sessionReady ? "给 Claude 发送消息…" : "等待会话..."}
|
||||
placeholder={sessionReady ? '给 Claude 发送消息…' : '等待会话...'}
|
||||
supportsImages={supportsImages}
|
||||
commands={availableCommands.length > 0 ? availableCommands : undefined}
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { cn } from "../src/lib/utils";
|
||||
import { User, Bot, Wrench, Loader2 } from "lucide-react";
|
||||
import { cn } from '../src/lib/utils';
|
||||
import { User, Bot, Wrench, Loader2 } from 'lucide-react';
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
title: string;
|
||||
status: "running" | "complete" | "error";
|
||||
status: 'running' | 'complete' | 'error';
|
||||
}
|
||||
|
||||
export interface ChatMessageData {
|
||||
id: string;
|
||||
role: "user" | "agent";
|
||||
role: 'user' | 'agent';
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
isStreaming?: boolean;
|
||||
@@ -20,36 +20,27 @@ interface ChatMessageProps {
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 p-4 rounded-lg",
|
||||
isUser ? "bg-muted/50" : "bg-background"
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex gap-3 p-4 rounded-lg', isUser ? 'bg-muted/50' : 'bg-background')}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center",
|
||||
isUser ? "bg-primary text-primary-foreground" : "bg-secondary"
|
||||
'flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center',
|
||||
isUser ? 'bg-primary text-primary-foreground' : 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="w-4 h-4" /> : <Bot className="w-4 h-4" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
{isUser ? "You" : "Agent"}
|
||||
</div>
|
||||
<div className="text-sm font-medium">{isUser ? 'You' : 'Agent'}</div>
|
||||
<div className="text-sm whitespace-pre-wrap break-words">
|
||||
{message.content}
|
||||
{message.isStreaming && (
|
||||
<span className="inline-block w-1.5 h-4 ml-0.5 bg-foreground animate-pulse" />
|
||||
)}
|
||||
{message.isStreaming && <span className="inline-block w-1.5 h-4 ml-0.5 bg-foreground animate-pulse" />}
|
||||
</div>
|
||||
{message.toolCalls && message.toolCalls.length > 0 && (
|
||||
<div className="space-y-1.5 pt-2">
|
||||
{message.toolCalls.map((tool) => (
|
||||
{message.toolCalls.map(tool => (
|
||||
<ToolCallDisplay key={tool.id} toolCall={tool} />
|
||||
))}
|
||||
</div>
|
||||
@@ -67,31 +58,34 @@ function ToolCallDisplay({ toolCall }: ToolCallDisplayProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-xs px-2 py-1.5 rounded border",
|
||||
toolCall.status === "running" && "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800",
|
||||
toolCall.status === "complete" && "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800",
|
||||
toolCall.status === "error" && "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"
|
||||
'flex items-center gap-2 text-xs px-2 py-1.5 rounded border',
|
||||
toolCall.status === 'running' && 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
|
||||
toolCall.status === 'complete' && 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
toolCall.status === 'error' && 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
|
||||
)}
|
||||
>
|
||||
{toolCall.status === "running" ? (
|
||||
{toolCall.status === 'running' ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin text-yellow-600 dark:text-yellow-400" />
|
||||
) : (
|
||||
<Wrench className={cn(
|
||||
"w-3 h-3",
|
||||
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
|
||||
toolCall.status === "error" && "text-red-600 dark:text-red-400"
|
||||
)} />
|
||||
<Wrench
|
||||
className={cn(
|
||||
'w-3 h-3',
|
||||
toolCall.status === 'complete' && 'text-green-600 dark:text-green-400',
|
||||
toolCall.status === 'error' && 'text-red-600 dark:text-red-400',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{toolCall.title}</span>
|
||||
<span className={cn(
|
||||
"ml-auto text-[10px] uppercase font-medium",
|
||||
toolCall.status === "running" && "text-yellow-600 dark:text-yellow-400",
|
||||
toolCall.status === "complete" && "text-green-600 dark:text-green-400",
|
||||
toolCall.status === "error" && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-[10px] uppercase font-medium',
|
||||
toolCall.status === 'running' && 'text-yellow-600 dark:text-yellow-400',
|
||||
toolCall.status === 'complete' && 'text-green-600 dark:text-green-400',
|
||||
toolCall.status === 'error' && 'text-red-600 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{toolCall.status}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Search, Clock, RefreshCw } from "lucide-react";
|
||||
import type { ACPClient } from "../src/acp/client";
|
||||
import type { AgentSessionInfo } from "../src/acp/types";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "../src/lib/utils";
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Search, Clock, RefreshCw } from 'lucide-react';
|
||||
import type { ACPClient } from '../src/acp/client';
|
||||
import type { AgentSessionInfo } from '../src/acp/types';
|
||||
import { Input } from './ui/input';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '../src/lib/utils';
|
||||
|
||||
// Reference: Zed's TimeBucket in thread_history.rs
|
||||
type TimeBucket = "today" | "yesterday" | "thisWeek" | "pastWeek" | "all";
|
||||
type TimeBucket = 'today' | 'yesterday' | 'thisWeek' | 'pastWeek' | 'all';
|
||||
|
||||
// Reference: Zed's Display impl for TimeBucket
|
||||
const BUCKET_LABELS: Record<TimeBucket, string> = {
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
thisWeek: "This Week",
|
||||
pastWeek: "Past Week",
|
||||
all: "All", // Zed uses "All", not "Older"
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
thisWeek: 'This Week',
|
||||
pastWeek: 'Past Week',
|
||||
all: 'All', // Zed uses "All", not "Older"
|
||||
};
|
||||
|
||||
// Reference: Zed's TimeBucket::from_dates (line 1028-1051)
|
||||
@@ -29,14 +29,14 @@ function getTimeBucket(date: Date): TimeBucket {
|
||||
|
||||
const entryDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
if (entryDate.getTime() === today.getTime()) return "today";
|
||||
if (entryDate.getTime() === yesterday.getTime()) return "yesterday";
|
||||
if (entryDate.getTime() === today.getTime()) return 'today';
|
||||
if (entryDate.getTime() === yesterday.getTime()) return 'yesterday';
|
||||
|
||||
// This week: same ISO week AND year
|
||||
const todayIsoWeek = getISOWeekYear(today);
|
||||
const entryIsoWeek = getISOWeekYear(entryDate);
|
||||
if (todayIsoWeek.year === entryIsoWeek.year && todayIsoWeek.week === entryIsoWeek.week) {
|
||||
return "thisWeek";
|
||||
return 'thisWeek';
|
||||
}
|
||||
|
||||
// Past week: (reference - 7days).iso_week()
|
||||
@@ -44,10 +44,10 @@ function getTimeBucket(date: Date): TimeBucket {
|
||||
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
|
||||
const lastWeekIsoWeek = getISOWeekYear(lastWeekDate);
|
||||
if (lastWeekIsoWeek.year === entryIsoWeek.year && lastWeekIsoWeek.week === entryIsoWeek.week) {
|
||||
return "pastWeek";
|
||||
return 'pastWeek';
|
||||
}
|
||||
|
||||
return "all";
|
||||
return 'all';
|
||||
}
|
||||
|
||||
// Returns ISO week number AND ISO week year (important for year boundaries)
|
||||
@@ -57,13 +57,13 @@ function getISOWeekYear(date: Date): { week: number; year: number } {
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const week = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
return { week, year: d.getUTCFullYear() }; // ISO week year, not calendar year
|
||||
return { week, year: d.getUTCFullYear() }; // ISO week year, not calendar year
|
||||
}
|
||||
|
||||
// Reference: Zed's formatted_time in HistoryEntryElement (line 904-921)
|
||||
// Exact format: Xd, Xh ago, Xm ago, Just now, Unknown
|
||||
function formatRelativeTime(date: Date | null): string {
|
||||
if (!date) return "Unknown"; // Zed uses "Unknown" for missing updatedAt
|
||||
if (!date) return 'Unknown'; // Zed uses "Unknown" for missing updatedAt
|
||||
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
@@ -74,7 +74,7 @@ function formatRelativeTime(date: Date | null): string {
|
||||
if (diffDays > 0) return `${diffDays}d`;
|
||||
if (diffHours > 0) return `${diffHours}h ago`;
|
||||
if (diffMinutes > 0) return `${diffMinutes}m ago`;
|
||||
return "Just now";
|
||||
return 'Just now';
|
||||
}
|
||||
|
||||
interface ThreadHistoryProps {
|
||||
@@ -90,7 +90,7 @@ interface GroupedSessions {
|
||||
|
||||
export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
const [sessions, setSessions] = useState<AgentSessionInfo[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Start with isLoading=true to prevent flash of "no threads" message
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -103,7 +103,7 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!client.supportsSessionList) {
|
||||
setError("Session list not supported by this agent");
|
||||
setError('Session list not supported by this agent');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = sessions.filter(
|
||||
(s) => s.title?.toLowerCase().includes(query) || s.sessionId.toLowerCase().includes(query)
|
||||
s => s.title?.toLowerCase().includes(query) || s.sessionId.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const dateA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
|
||||
const dateB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
|
||||
return dateB - dateA; // Descending
|
||||
return dateB - dateA; // Descending
|
||||
});
|
||||
|
||||
// Group by time bucket (preserving sort order within each bucket)
|
||||
@@ -161,10 +161,8 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
}
|
||||
|
||||
// Return in chronological bucket order
|
||||
const bucketOrder: TimeBucket[] = ["today", "yesterday", "thisWeek", "pastWeek", "all"];
|
||||
return bucketOrder
|
||||
.filter((b) => groups.has(b))
|
||||
.map((bucket) => ({ bucket, sessions: groups.get(bucket)! }));
|
||||
const bucketOrder: TimeBucket[] = ['today', 'yesterday', 'thisWeek', 'pastWeek', 'all'];
|
||||
return bucketOrder.filter(b => groups.has(b)).map(bucket => ({ bucket, sessions: groups.get(bucket)! }));
|
||||
}, [sessions, searchQuery]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
@@ -179,7 +177,7 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
setLoadingSessionId(null);
|
||||
}
|
||||
},
|
||||
[onSelectSession, loadingSessionId]
|
||||
[onSelectSession, loadingSessionId],
|
||||
);
|
||||
|
||||
if (!supportsHistory) {
|
||||
@@ -191,7 +189,7 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const flatItems = groupedSessions.flatMap((g) => g.sessions);
|
||||
const flatItems = groupedSessions.flatMap(g => g.sessions);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -201,25 +199,17 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
<Input
|
||||
placeholder="Search threads..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="h-8 border-0 focus-visible:ring-0 shadow-none"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={loadSessions}
|
||||
disabled={isLoading}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
<Button variant="ghost" size="sm" onClick={loadSessions} disabled={isLoading} className="shrink-0">
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{error && (
|
||||
<div className="p-4 text-center text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
{error && <div className="p-4 text-center text-destructive text-sm">{error}</div>}
|
||||
|
||||
{!error && isLoading && sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
@@ -230,17 +220,13 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
|
||||
{!error && !isLoading && sessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
You don't have any past threads yet.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">You don't have any past threads yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && sessions.length > 0 && groupedSessions.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No threads match your search.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm">No threads match your search.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -249,14 +235,12 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
{groupedSessions.map((group, groupIndex) => (
|
||||
<div key={group.bucket}>
|
||||
{/* Bucket separator - Reference: Zed's BucketSeparator */}
|
||||
<div className={cn("px-2 pb-1", groupIndex > 0 && "pt-3")}>
|
||||
<span className="text-xs text-muted-foreground font-medium">
|
||||
{BUCKET_LABELS[group.bucket]}
|
||||
</span>
|
||||
<div className={cn('px-2 pb-1', groupIndex > 0 && 'pt-3')}>
|
||||
<span className="text-xs text-muted-foreground font-medium">{BUCKET_LABELS[group.bucket]}</span>
|
||||
</div>
|
||||
|
||||
{/* Session entries */}
|
||||
{group.sessions.map((session) => {
|
||||
{group.sessions.map(session => {
|
||||
const globalIdx = flatItems.indexOf(session);
|
||||
const isSelected = globalIdx === selectedIndex;
|
||||
const isLoadingThis = loadingSessionId === session.sessionId;
|
||||
@@ -273,23 +257,19 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
}}
|
||||
className={cn(
|
||||
// min-w-0 is required for truncate to work in flex containers
|
||||
"w-full min-w-0 flex items-center gap-2 px-3 py-2 rounded-md text-left transition-colors",
|
||||
"hover:bg-accent",
|
||||
isSelected && "bg-accent",
|
||||
isAnyLoading && !isLoadingThis && "opacity-50 cursor-not-allowed",
|
||||
isLoadingThis && "bg-accent"
|
||||
'w-full min-w-0 flex items-center gap-2 px-3 py-2 rounded-md text-left transition-colors',
|
||||
'hover:bg-accent',
|
||||
isSelected && 'bg-accent',
|
||||
isAnyLoading && !isLoadingThis && 'opacity-50 cursor-not-allowed',
|
||||
isLoadingThis && 'bg-accent',
|
||||
)}
|
||||
>
|
||||
{/* min-w-0 + truncate ensures long titles are clipped with ellipsis */}
|
||||
<span className="text-sm truncate flex-1 min-w-0">
|
||||
{session.title && session.title.trim() ? session.title : "New Thread"}
|
||||
{session.title && session.title.trim() ? session.title : 'New Thread'}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0 whitespace-nowrap">
|
||||
{isLoadingThis ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
formatRelativeTime(date)
|
||||
)}
|
||||
{isLoadingThis ? <RefreshCw className="h-3 w-3 animate-spin" /> : formatRelativeTime(date)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -301,4 +281,3 @@ export function ThreadHistory({ client, onSelectSession }: ThreadHistoryProps) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||||
import { type ComponentProps, createContext, type HTMLAttributes, useContext, useState } from 'react';
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
@@ -22,7 +16,7 @@ type CodeBlockContextType = {
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
code: '',
|
||||
});
|
||||
|
||||
export const CodeBlock = ({
|
||||
@@ -33,14 +27,14 @@ export const CodeBlock = ({
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const lines = code.split("\n");
|
||||
const lines = code.split('\n');
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
'group relative w-full overflow-hidden rounded-md border bg-background text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -57,7 +51,7 @@ export const CodeBlock = ({
|
||||
)}
|
||||
<td className="p-0">
|
||||
<pre className="m-0 p-0 text-sm whitespace-pre font-mono">
|
||||
<code className="text-sm">{line || "\u00A0"}</code>
|
||||
<code className="text-sm">{line || '\u00A0'}</code>
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -65,11 +59,7 @@ export const CodeBlock = ({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
{children && <div className="absolute top-2 right-2 flex items-center gap-2">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
@@ -94,8 +84,8 @@ export const CodeBlockCopyButton = ({
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error('Clipboard API not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -112,13 +102,7 @@ export const CodeBlockCopyButton = ({
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<Button className={cn('shrink-0', className)} onClick={copyToClipboard} size="icon" variant="ghost" {...props}>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ArrowDownIcon, UserIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { ArrowDownIcon, UserIcon } from 'lucide-react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden overflow-x-hidden", className)}
|
||||
className={cn('relative flex-1 overflow-y-hidden overflow-x-hidden', className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
@@ -19,21 +19,16 @@ export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
export type ConversationContentProps = ComponentProps<typeof StickToBottom.Content>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
export const ConversationContent = ({ className, ...props }: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8 sm:px-8 sm:py-12 min-w-0", className)}
|
||||
className={cn('mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8 sm:px-8 sm:py-12 min-w-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
export type ConversationEmptyStateProps = ComponentProps<'div'> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
@@ -41,17 +36,14 @@ export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
title = 'No messages yet',
|
||||
description = 'Start a conversation to see messages here',
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-4 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
className={cn('flex size-full flex-col items-center justify-center gap-4 p-8 text-center', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
@@ -59,9 +51,7 @@ export const ConversationEmptyState = ({
|
||||
{icon && <div className="text-text-muted">{icon}</div>}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-base font-display text-text-primary">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-text-muted text-sm leading-relaxed max-w-xs">{description}</p>
|
||||
)}
|
||||
{description && <p className="text-text-muted text-sm leading-relaxed max-w-xs">{description}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -76,10 +66,7 @@ export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
* When used standalone, it handles its own visibility based on isAtBottom.
|
||||
* When used in ConversationScrollButtons, the container manages visibility.
|
||||
*/
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
export const ConversationScrollButton = ({ className, ...props }: ConversationScrollButtonProps) => {
|
||||
const { scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
@@ -88,10 +75,7 @@ export const ConversationScrollButton = ({
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
className
|
||||
)}
|
||||
className={cn('rounded-full', className)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
@@ -108,7 +92,7 @@ export const ConversationScrollButton = ({
|
||||
* Data attribute used to mark the last user message element.
|
||||
* ChatInterface adds this attribute to the last user message for scroll targeting.
|
||||
*/
|
||||
export const LAST_USER_MESSAGE_ATTR = "data-last-user-message";
|
||||
export const LAST_USER_MESSAGE_ATTR = 'data-last-user-message';
|
||||
|
||||
export type ConversationScrollToLastUserMessageButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
@@ -124,16 +108,13 @@ export const ConversationScrollToLastUserMessageButton = ({
|
||||
// Find the last user message element by data attribute
|
||||
const lastUserMessage = document.querySelector(`[${LAST_USER_MESSAGE_ATTR}="true"]`);
|
||||
if (lastUserMessage) {
|
||||
lastUserMessage.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
lastUserMessage.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
className
|
||||
)}
|
||||
className={cn('rounded-full', className)}
|
||||
onClick={handleScrollToLastUserMessage}
|
||||
size="icon"
|
||||
type="button"
|
||||
@@ -146,7 +127,7 @@ export const ConversationScrollToLastUserMessageButton = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type ConversationScrollButtonsProps = ComponentProps<"div"> & {
|
||||
export type ConversationScrollButtonsProps = ComponentProps<'div'> & {
|
||||
/** Whether there are user messages to scroll to */
|
||||
hasUserMessages?: boolean;
|
||||
};
|
||||
@@ -166,16 +147,9 @@ export const ConversationScrollButtons = ({
|
||||
if (isAtBottom) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2', className)} {...props}>
|
||||
{hasUserMessages && <ConversationScrollToLastUserMessageButton />}
|
||||
<ConversationScrollButton />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export * from "./code-block";
|
||||
export * from "./conversation";
|
||||
export * from "./message";
|
||||
export * from "./permission-request";
|
||||
export * from "./prompt-input";
|
||||
export * from "./reasoning";
|
||||
export * from "./shimmer";
|
||||
export * from "./tool";
|
||||
|
||||
export * from './code-block'
|
||||
export * from './conversation'
|
||||
export * from './message'
|
||||
export * from './permission-request'
|
||||
export * from './prompt-input'
|
||||
export * from './reasoning'
|
||||
export * from './shimmer'
|
||||
export * from './tool'
|
||||
|
||||
@@ -1,39 +1,26 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "../ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../ui/tooltip";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, lazy, memo, Suspense, useContext, useEffect, useState } from "react";
|
||||
import { Button } from '../ui/button';
|
||||
import { ButtonGroup, ButtonGroupText } from '../ui/button-group';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import type { FileUIPart, UIMessage } from 'ai';
|
||||
import { ChevronLeftIcon, ChevronRightIcon, PaperclipIcon, XIcon } from 'lucide-react';
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from 'react';
|
||||
import { createContext, lazy, memo, Suspense, useContext, useEffect, useState } from 'react';
|
||||
|
||||
const LazyStreamdown = lazy(() => import("streamdown").then((m) => ({ default: m.Streamdown })));
|
||||
const LazyStreamdown = lazy(() => import('streamdown').then(m => ({ default: m.Streamdown })));
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
from: UIMessage['role'];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[85%] min-w-0 flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
'group flex w-full max-w-[85%] min-w-0 flex-col gap-2',
|
||||
from === 'user' ? 'is-user ml-auto justify-end' : 'is-assistant',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -41,33 +28,25 @@ export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit max-w-full flex-col gap-2 overflow-hidden text-sm break-words",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
'is-user:dark flex w-fit max-w-full flex-col gap-2 overflow-hidden text-sm break-words',
|
||||
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground',
|
||||
'group-[.is-assistant]:text-foreground',
|
||||
className,
|
||||
)}
|
||||
style={{ overflowWrap: "anywhere" }}
|
||||
style={{ overflowWrap: 'anywhere' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
export type MessageActionsProps = ComponentProps<'div'>;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
export const MessageActions = ({ className, children, ...props }: MessageActionsProps) => (
|
||||
<div className={cn('flex items-center gap-1', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -81,8 +60,8 @@ export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
variant = 'ghost',
|
||||
size = 'icon-sm',
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
@@ -117,17 +96,13 @@ type MessageBranchContextType = {
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(null);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
throw new Error('MessageBranch components must be used within MessageBranch');
|
||||
}
|
||||
|
||||
return context;
|
||||
@@ -138,12 +113,7 @@ export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
export const MessageBranch = ({ defaultBranch = 0, onBranchChange, className, ...props }: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
@@ -153,14 +123,12 @@ export const MessageBranch = ({
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
const newBranch = currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
const newBranch = currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
@@ -175,20 +143,14 @@ export const MessageBranch = ({
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
<div className={cn('grid w-full gap-2 [&>div]:pb-0', className)} {...props} />
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
export const MessageBranchContent = ({ children, ...props }: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
@@ -201,10 +163,7 @@ export const MessageBranchContent = ({
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
className={cn('grid gap-2 overflow-hidden [&>div]:pb-0', index === currentBranch ? 'block' : 'hidden')}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
@@ -214,14 +173,10 @@ export const MessageBranchContent = ({
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
from: UIMessage['role'];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
export const MessageBranchSelector = ({ className, from, ...props }: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
@@ -240,10 +195,7 @@ export const MessageBranchSelector = ({
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
export const MessageBranchPrevious = ({ children, ...props }: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
@@ -263,11 +215,7 @@ export const MessageBranchPrevious = ({
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
export const MessageBranchNext = ({ children, className, ...props }: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
@@ -287,18 +235,12 @@ export const MessageBranchNext = ({
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
export const MessageBranchPage = ({ className, ...props }: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
className={cn('border-none bg-transparent text-muted-foreground shadow-none', className)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
@@ -309,22 +251,16 @@ export const MessageBranchPage = ({
|
||||
export type MessageResponseProps = {
|
||||
children?: string;
|
||||
className?: string;
|
||||
mode?: "static" | "streaming";
|
||||
mode?: 'static' | 'streaming';
|
||||
};
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, children, ...props }: MessageResponseProps) => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className={cn("whitespace-pre-wrap break-words", className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={<div className={cn('whitespace-pre-wrap break-words', className)}>{children}</div>}>
|
||||
<LazyStreamdown
|
||||
className={cn(
|
||||
"size-full break-words [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
'size-full break-words [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -332,10 +268,10 @@ export const MessageResponse = memo(
|
||||
</LazyStreamdown>
|
||||
</Suspense>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children,
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
MessageResponse.displayName = 'MessageResponse';
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
@@ -343,30 +279,18 @@ export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
export function MessageAttachment({ data, className, onRemove, ...props }: MessageAttachmentProps) {
|
||||
const filename = data.filename || '';
|
||||
const mediaType = data.mediaType?.startsWith('image/') && data.url ? 'image' : 'file';
|
||||
const isImage = mediaType === 'image';
|
||||
const attachmentLabel = filename || (isImage ? 'Image' : 'Attachment');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('group relative size-24 overflow-hidden rounded-lg', className)} {...props}>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
alt={filename || 'attachment'}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
@@ -376,7 +300,7 @@ export function MessageAttachment({
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
@@ -404,7 +328,7 @@ export function MessageAttachment({
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
@@ -421,45 +345,24 @@ export function MessageAttachment({
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
export type MessageAttachmentsProps = ComponentProps<'div'>;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
export function MessageAttachments({ children, className, ...props }: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={cn('ml-auto flex w-fit flex-wrap items-start gap-2', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
export type MessageToolbarProps = ComponentProps<'div'>;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
export const MessageToolbar = ({ className, children, ...props }: MessageToolbarProps) => (
|
||||
<div className={cn('mt-4 flex w-full items-center justify-between gap-4', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { ShieldAlertIcon, CheckIcon, XIcon } from "lucide-react";
|
||||
import type { PermissionOption } from "../../src/acp/types";
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { ShieldAlertIcon, CheckIcon, XIcon } from 'lucide-react';
|
||||
import type { PermissionOption } from '../../src/acp/types';
|
||||
|
||||
// Get button variant based on option kind
|
||||
function getButtonVariant(kind: PermissionOption["kind"]): "default" | "destructive" | "outline" | "secondary" {
|
||||
function getButtonVariant(kind: PermissionOption['kind']): 'default' | 'destructive' | 'outline' | 'secondary' {
|
||||
switch (kind) {
|
||||
case "allow_once":
|
||||
case "allow_always":
|
||||
return "default";
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
return "destructive";
|
||||
case 'allow_once':
|
||||
case 'allow_always':
|
||||
return 'default';
|
||||
case 'reject_once':
|
||||
case 'reject_always':
|
||||
return 'destructive';
|
||||
default:
|
||||
return "outline";
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
// Get button icon based on option kind
|
||||
function getButtonIcon(kind: PermissionOption["kind"]) {
|
||||
function getButtonIcon(kind: PermissionOption['kind']) {
|
||||
switch (kind) {
|
||||
case "allow_once":
|
||||
case "allow_always":
|
||||
case 'allow_once':
|
||||
case 'allow_always':
|
||||
return <CheckIcon className="size-4" />;
|
||||
case "reject_once":
|
||||
case "reject_always":
|
||||
case 'reject_once':
|
||||
case 'reject_always':
|
||||
return <XIcon className="size-4" />;
|
||||
default:
|
||||
return null;
|
||||
@@ -37,7 +37,7 @@ function getButtonIcon(kind: PermissionOption["kind"]) {
|
||||
export interface ToolPermissionButtonsProps {
|
||||
requestId: string;
|
||||
options: PermissionOption[];
|
||||
onRespond: (requestId: string, optionId: string | null, optionKind: PermissionOption["kind"] | null) => void;
|
||||
onRespond: (requestId: string, optionId: string | null, optionKind: PermissionOption['kind'] | null) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -47,15 +47,13 @@ export function ToolPermissionButtons({ requestId, options, onRespond, className
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("p-3 border-t border-warning-border/30 bg-warning-bg/50", className)}>
|
||||
<div className={cn('p-3 border-t border-warning-border/30 bg-warning-bg/50', className)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<ShieldAlertIcon className="size-4 text-warning-text" />
|
||||
<span className="text-xs font-medium text-warning-text">
|
||||
Permission Required
|
||||
</span>
|
||||
<span className="text-xs font-medium text-warning-text">Permission Required</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option) => (
|
||||
{options.map(option => (
|
||||
<Button
|
||||
key={option.optionId}
|
||||
variant={getButtonVariant(option.kind)}
|
||||
@@ -71,4 +69,3 @@ export function ToolPermissionButtons({ requestId, options, onRespond, className
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,12 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useState } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { useControllableState } from '@radix-ui/react-use-controllable-state';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { BrainIcon, ChevronDownIcon } from 'lucide-react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { createContext, memo, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { Shimmer } from './shimmer';
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
@@ -24,7 +20,7 @@ const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
throw new Error('Reasoning components must be used within Reasoning');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -78,8 +74,8 @@ export const Reasoning = memo(
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
// Respect prefers-reduced-motion: skip animation auto-close
|
||||
const prefersReducedMotion = typeof window !== "undefined"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const prefersReducedMotion =
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefersReducedMotion && defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
@@ -97,11 +93,9 @@ export const Reasoning = memo(
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen: isOpen ?? false, setIsOpen, duration }}
|
||||
>
|
||||
<ReasoningContext.Provider value={{ isStreaming, isOpen: isOpen ?? false, setIsOpen, duration }}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
className={cn('not-prose mb-4', className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
@@ -110,12 +104,10 @@ export const Reasoning = memo(
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
@@ -130,19 +122,14 @@ const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
({ className, children, getThinkingMessage = defaultGetThinkingMessage, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
'flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -150,41 +137,31 @@ export const ReasoningTrigger = memo(
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
<ChevronDownIcon className={cn('size-4 transition-transform', isOpen ? 'rotate-180' : 'rotate-0')} />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
export type ReasoningContentProps = ComponentProps<typeof CollapsibleContent> & {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
export const ReasoningContent = memo(({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
'mt-4 text-sm',
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
));
|
||||
|
||||
Reasoning.displayName = 'Reasoning';
|
||||
ReasoningTrigger.displayName = 'ReasoningTrigger';
|
||||
ReasoningContent.displayName = 'ReasoningContent';
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
} from "react";
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { motion } from 'motion/react';
|
||||
import { type ElementType, type JSX, memo } from 'react';
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
@@ -16,27 +12,17 @@ export interface TextShimmerProps {
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
const ShimmerComponent = ({ children, as: Component = 'p', className, duration = 2 }: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(Component as keyof JSX.IntrinsicElements);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
className={cn(
|
||||
"relative inline-block text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
className={cn('relative inline-block text-muted-foreground', className)}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "easeInOut",
|
||||
ease: 'easeInOut',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,67 +1,56 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import { Badge } from "../ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "../ui/collapsible";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import type { ToolUIPart } from 'ai';
|
||||
import { CheckCircleIcon, ChevronDownIcon, CircleIcon, ClockIcon, WrenchIcon, XCircleIcon } from 'lucide-react';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { isValidElement } from 'react';
|
||||
import { CodeBlock } from './code-block';
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full max-w-full overflow-hidden rounded-md border", className)}
|
||||
className={cn('not-prose mb-4 w-full max-w-full overflow-hidden rounded-md border', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// Extended state type to include our custom states
|
||||
export type ExtendedToolState = ToolUIPart["state"] | "waiting-for-confirmation" | "rejected";
|
||||
export type ExtendedToolState = ToolUIPart['state'] | 'waiting-for-confirmation' | 'rejected';
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
type: ToolUIPart['type'];
|
||||
state: ExtendedToolState;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ExtendedToolState) => {
|
||||
const labels: Record<ExtendedToolState, string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
"waiting-for-confirmation": "Awaiting Approval",
|
||||
"rejected": "Rejected",
|
||||
'input-streaming': 'Pending',
|
||||
'input-available': 'Running',
|
||||
'approval-requested': 'Awaiting Approval',
|
||||
'approval-responded': 'Responded',
|
||||
'output-available': 'Completed',
|
||||
'output-error': 'Error',
|
||||
'output-denied': 'Denied',
|
||||
'waiting-for-confirmation': 'Awaiting Approval',
|
||||
rejected: 'Rejected',
|
||||
};
|
||||
|
||||
const icons: Record<ExtendedToolState, ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"waiting-for-confirmation": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"rejected": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
'input-streaming': <CircleIcon className="size-4" />,
|
||||
'input-available': <ClockIcon className="size-4 animate-pulse" />,
|
||||
'approval-requested': <ClockIcon className="size-4 text-yellow-600" />,
|
||||
'approval-responded': <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
'output-available': <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
'output-error': <XCircleIcon className="size-4 text-red-600" />,
|
||||
'output-denied': <XCircleIcon className="size-4 text-orange-600" />,
|
||||
'waiting-for-confirmation': <ClockIcon className="size-4 text-yellow-600" />,
|
||||
rejected: <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -72,25 +61,11 @@ const getStatusBadge = (status: ExtendedToolState) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
export const ToolHeader = ({ className, title, type, state, ...props }: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger className={cn('flex w-full items-center justify-between gap-4 p-3', className)} {...props}>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-sm">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
<span className="truncate font-medium text-sm">{title ?? type.split('-').slice(1).join('-')}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
@@ -102,64 +77,53 @@ export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"];
|
||||
export type ToolInputProps = ComponentProps<'div'> & {
|
||||
input: ToolUIPart['input'];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4 max-w-full", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className={cn('space-y-2 overflow-hidden p-4 max-w-full', className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">Parameters</h4>
|
||||
<div className="rounded-md bg-muted/50 overflow-hidden">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["errorText"];
|
||||
export type ToolOutputProps = ComponentProps<'div'> & {
|
||||
output: ToolUIPart['output'];
|
||||
errorText: ToolUIPart['errorText'];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
export const ToolOutput = ({ className, output, errorText, ...props }: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
if (typeof output === 'object' && !isValidElement(output)) {
|
||||
Output = <CodeBlock code={JSON.stringify(output, null, 2)} language="json" />;
|
||||
} else if (typeof output === 'string') {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4 max-w-full overflow-hidden", className)} {...props}>
|
||||
<div className={cn('space-y-2 p-4 max-w-full overflow-hidden', className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
{errorText ? 'Error' : 'Result'}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
'overflow-hidden rounded-md text-xs [&_table]:w-full',
|
||||
errorText ? 'bg-destructive/10 text-destructive' : 'bg-muted/50 text-foreground',
|
||||
)}
|
||||
>
|
||||
{errorText && <div className="p-2">{errorText}</div>}
|
||||
@@ -168,4 +132,3 @@ export const ToolOutput = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useRef, useCallback, type KeyboardEvent, type ClipboardEvent } from "react";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Send, Square, Paperclip, Slash } from "lucide-react";
|
||||
import type { ChatInputMessage, UserMessageImage } from "../../src/lib/types";
|
||||
import type { AvailableCommand } from "../../src/acp/types";
|
||||
import { CommandMenu } from "./CommandMenu";
|
||||
import imageCompression from "browser-image-compression";
|
||||
import { useState, useRef, useCallback, type KeyboardEvent, type ClipboardEvent } from 'react';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { Send, Square, Paperclip, Slash } from 'lucide-react';
|
||||
import type { ChatInputMessage, UserMessageImage } from '../../src/lib/types';
|
||||
import type { AvailableCommand } from '../../src/acp/types';
|
||||
import { CommandMenu } from './CommandMenu';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
|
||||
// 图片压缩配置
|
||||
const IMAGE_COMPRESSION_OPTIONS = {
|
||||
maxSizeMB: 2,
|
||||
maxWidthOrHeight: 2048,
|
||||
useWebWorker: true,
|
||||
fileType: "image/jpeg" as const,
|
||||
fileType: 'image/jpeg' as const,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
@@ -36,15 +36,15 @@ export function ChatInput({
|
||||
isLoading = false,
|
||||
onInterrupt,
|
||||
disabled = false,
|
||||
placeholder = "给 Claude 发送消息…",
|
||||
placeholder = '给 Claude 发送消息…',
|
||||
supportsImages = false,
|
||||
commands,
|
||||
className,
|
||||
}: ChatInputProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [text, setText] = useState('');
|
||||
const [images, setImages] = useState<UserMessageImage[]>([]);
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
const [commandFilter, setCommandFilter] = useState("");
|
||||
const [commandFilter, setCommandFilter] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -53,37 +53,37 @@ export function ChatInput({
|
||||
if ((!trimmed && images.length === 0) || disabled) return;
|
||||
|
||||
onSubmit({ text: trimmed, images: images.length > 0 ? images : undefined });
|
||||
setText("");
|
||||
setText('');
|
||||
setImages([]);
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
setCommandFilter('');
|
||||
// 重置 textarea 高度
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}, [text, images, disabled, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showCommandMenu) {
|
||||
if (e.key === "Escape") {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
// Arrow keys and Enter are handled by CommandMenu via document-level listener
|
||||
// Don't submit or move cursor when menu is open
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter") {
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Tab") {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
if (isLoading) {
|
||||
onInterrupt?.();
|
||||
@@ -95,35 +95,41 @@ export function ChatInput({
|
||||
[handleSubmit, isLoading, onInterrupt, showCommandMenu],
|
||||
);
|
||||
|
||||
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setText(value);
|
||||
const handleInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setText(value);
|
||||
|
||||
// 检测 slash 命令模式:仅在输入开头输入 / 时触发
|
||||
if (value.startsWith("/") && commands && commands.length > 0) {
|
||||
setShowCommandMenu(true);
|
||||
setCommandFilter(value.slice(1).split(/\s/)[0] || "");
|
||||
} else if (showCommandMenu) {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
}
|
||||
// 检测 slash 命令模式:仅在输入开头输入 / 时触发
|
||||
if (value.startsWith('/') && commands && commands.length > 0) {
|
||||
setShowCommandMenu(true);
|
||||
setCommandFilter(value.slice(1).split(/\s/)[0] || '');
|
||||
} else if (showCommandMenu) {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter('');
|
||||
}
|
||||
|
||||
// 自动调整高度
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
||||
}, [commands, showCommandMenu]);
|
||||
// 自动调整高度
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
},
|
||||
[commands, showCommandMenu],
|
||||
);
|
||||
|
||||
// 粘贴图片
|
||||
const handlePaste = useCallback(async (e: ClipboardEvent) => {
|
||||
if (!supportsImages) return;
|
||||
const files = Array.from(e.clipboardData.files).filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length === 0) return;
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent) => {
|
||||
if (!supportsImages) return;
|
||||
const files = Array.from(e.clipboardData.files).filter(f => f.type.startsWith('image/'));
|
||||
if (files.length === 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
const newImages = await processImageFiles(files);
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
}, [supportsImages]);
|
||||
e.preventDefault();
|
||||
const newImages = await processImageFiles(files);
|
||||
setImages(prev => [...prev, ...newImages]);
|
||||
},
|
||||
[supportsImages],
|
||||
);
|
||||
|
||||
// 选择文件
|
||||
const handleFileSelect = useCallback(async () => {
|
||||
@@ -132,32 +138,32 @@ export function ChatInput({
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newImages = await processImageFiles(Array.from(files));
|
||||
setImages((prev) => [...prev, ...newImages]);
|
||||
setImages(prev => [...prev, ...newImages]);
|
||||
// 清空 input 以便重复选择
|
||||
fileInputRef.current.value = "";
|
||||
fileInputRef.current.value = '';
|
||||
}, []);
|
||||
|
||||
const removeImage = useCallback((index: number) => {
|
||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||
setImages(prev => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleCommandSelect = useCallback((command: AvailableCommand) => {
|
||||
setText(`/${command.name} `);
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
setCommandFilter('');
|
||||
textareaRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const toggleCommandMenu = useCallback(() => {
|
||||
if (showCommandMenu) {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
setCommandFilter('');
|
||||
} else {
|
||||
if (!text.startsWith("/")) {
|
||||
setText("/" + text);
|
||||
if (!text.startsWith('/')) {
|
||||
setText('/' + text);
|
||||
}
|
||||
setShowCommandMenu(true);
|
||||
setCommandFilter(text.startsWith("/") ? text.slice(1).split(/\s/)[0] || "" : "");
|
||||
setCommandFilter(text.startsWith('/') ? text.slice(1).split(/\s/)[0] || '' : '');
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [showCommandMenu, text]);
|
||||
@@ -165,7 +171,7 @@ export function ChatInput({
|
||||
const canSend = (text.trim() || images.length > 0) && !disabled;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-4 sm:px-8 pb-4 pt-2", className)}>
|
||||
<div className={cn('w-full max-w-3xl mx-auto px-4 sm:px-8 pb-4 pt-2', className)}>
|
||||
<div className="relative">
|
||||
{/* Slash command menu — floating above input */}
|
||||
{showCommandMenu && commands && commands.length > 0 && (
|
||||
@@ -175,127 +181,124 @@ export function ChatInput({
|
||||
onSelect={handleCommandSelect}
|
||||
onClose={() => {
|
||||
setShowCommandMenu(false);
|
||||
setCommandFilter("");
|
||||
setCommandFilter('');
|
||||
}}
|
||||
className="absolute bottom-full left-0 right-0 mb-1 z-50"
|
||||
/>
|
||||
)}
|
||||
<div className={cn(
|
||||
"rounded-xl border border-border bg-surface-2 overflow-hidden",
|
||||
"focus-within:border-brand/50 focus-within:shadow-[0_0_0_3px_rgba(217,119,87,0.15)] transition-all",
|
||||
)}>
|
||||
{/* 图片预览 */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-3 pt-3">
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={`data:${img.mimeType};base64,${img.data}`}
|
||||
alt={`Attached image ${i + 1}`}
|
||||
className="h-14 w-14 object-cover rounded-lg border border-border"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-border bg-surface-2 overflow-hidden',
|
||||
'focus-within:border-brand/50 focus-within:shadow-[0_0_0_3px_rgba(217,119,87,0.15)] transition-all',
|
||||
)}
|
||||
>
|
||||
{/* 图片预览 */}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-3 pt-3">
|
||||
{images.map((img, i) => (
|
||||
<div key={i} className="relative group">
|
||||
<img
|
||||
src={`data:${img.mimeType};base64,${img.data}`}
|
||||
alt={`Attached image ${i + 1}`}
|
||||
className="h-14 w-14 object-cover rounded-lg border border-border"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-1.5 -right-1.5 min-h-[32px] min-w-[32px] h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove image ${i + 1}`}
|
||||
>
|
||||
{'\u00D7'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 输入区域 — Anthropic 单行紧凑布局 */}
|
||||
<div className="flex items-end gap-2 px-3 py-2.5">
|
||||
{/* 左侧附件按钮 */}
|
||||
{supportsImages && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(i)}
|
||||
className="absolute -top-1.5 -right-1.5 min-h-[32px] min-w-[32px] h-5 w-5 rounded-full bg-surface-2 border border-border flex items-center justify-center text-text-muted hover:text-text-primary text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Remove image ${i + 1}`}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-secondary hover:bg-surface-1/50 transition-colors"
|
||||
disabled={disabled}
|
||||
>
|
||||
{"\u00D7"}
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span className="sr-only">Attach file</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 输入区域 — Anthropic 单行紧凑布局 */}
|
||||
<div className="flex items-end gap-2 px-3 py-2.5">
|
||||
{/* 左侧附件按钮 */}
|
||||
{supportsImages && (
|
||||
<>
|
||||
{/* Slash 命令按钮 */}
|
||||
{commands && commands.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg text-text-muted hover:text-text-secondary hover:bg-surface-1/50 transition-colors"
|
||||
onClick={toggleCommandMenu}
|
||||
className={cn(
|
||||
'flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-colors',
|
||||
showCommandMenu
|
||||
? 'bg-brand/15 text-brand'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-surface-1/50',
|
||||
)}
|
||||
disabled={disabled}
|
||||
title="命令列表"
|
||||
>
|
||||
<Paperclip className="h-4 w-4" />
|
||||
<span className="sr-only">Attach file</span>
|
||||
<Slash className="h-4 w-4" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Slash 命令按钮 */}
|
||||
{commands && commands.length > 0 && (
|
||||
{/* Textarea — Poppins font */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
'flex-1 resize-none border-none bg-transparent outline-none',
|
||||
'text-sm text-text-primary placeholder:text-text-muted font-display',
|
||||
'max-h-[200px] min-h-[24px] leading-normal',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 右侧发送/取消按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleCommandMenu}
|
||||
onClick={isLoading ? onInterrupt : handleSubmit}
|
||||
disabled={!isLoading && !canSend}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-colors",
|
||||
showCommandMenu
|
||||
? "bg-brand/15 text-brand"
|
||||
: "text-text-muted hover:text-text-secondary hover:bg-surface-1/50",
|
||||
'flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-all',
|
||||
isLoading
|
||||
? 'bg-text-primary text-surface-2 hover:bg-text-secondary'
|
||||
: canSend
|
||||
? 'bg-brand text-white hover:bg-brand-light hover:scale-[1.05] active:scale-[0.97]'
|
||||
: 'bg-surface-1 text-text-muted',
|
||||
)}
|
||||
disabled={disabled}
|
||||
title="命令列表"
|
||||
>
|
||||
<Slash className="h-4 w-4" />
|
||||
{isLoading ? <Square className="h-3.5 w-3.5" fill="currentColor" /> : <Send className="h-4 w-4" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Textarea — Poppins font */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"flex-1 resize-none border-none bg-transparent outline-none",
|
||||
"text-sm text-text-primary placeholder:text-text-muted font-display",
|
||||
"max-h-[200px] min-h-[24px] leading-normal",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 右侧发送/取消按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={isLoading ? onInterrupt : handleSubmit}
|
||||
disabled={!isLoading && !canSend}
|
||||
className={cn(
|
||||
"flex-shrink-0 h-8 w-8 flex items-center justify-center rounded-lg transition-all",
|
||||
isLoading
|
||||
? "bg-text-primary text-surface-2 hover:bg-text-secondary"
|
||||
: canSend
|
||||
? "bg-brand text-white hover:bg-brand-light hover:scale-[1.05] active:scale-[0.97]"
|
||||
: "bg-surface-1 text-text-muted",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Square className="h-3.5 w-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end relative */}
|
||||
{/* end relative */}
|
||||
|
||||
{/* 提示文本 */}
|
||||
<div className="text-center mt-1.5">
|
||||
<span className="text-[11px] text-text-muted font-display">
|
||||
Enter 发送,Shift+Enter 换行
|
||||
</span>
|
||||
<span className="text-[11px] text-text-muted font-display">Enter 发送,Shift+Enter 换行</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -316,23 +319,23 @@ async function processImageFiles(files: File[]): Promise<UserMessageImage[]> {
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
const compressed = await imageCompression(file, IMAGE_COMPRESSION_OPTIONS);
|
||||
blob = compressed;
|
||||
mimeType = "image/jpeg";
|
||||
mimeType = 'image/jpeg';
|
||||
}
|
||||
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
const result = reader.result as string;
|
||||
const commaIdx = result.indexOf(",");
|
||||
const commaIdx = result.indexOf(',');
|
||||
resolve(commaIdx >= 0 ? result.slice(commaIdx + 1) : result);
|
||||
};
|
||||
reader.onerror = () => reject(new Error("FileReader error"));
|
||||
reader.onerror = () => reject(new Error('FileReader error'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
results.push({ mimeType, data: base64 });
|
||||
} catch (err) {
|
||||
console.error("Failed to process image:", err);
|
||||
console.error('Failed to process image:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import type { ThreadEntry, ToolCallEntry, PlanDisplayEntry } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
import { ToolCallGroup } from "./ToolCallGroup";
|
||||
import { PlanDisplay } from "./PlanView";
|
||||
import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButtons } from "../ai-elements/conversation";
|
||||
import type { ThreadEntry, ToolCallEntry, PlanDisplayEntry } from '../../src/lib/types';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { UserBubble, AssistantBubble } from './MessageBubble';
|
||||
import { ToolCallGroup } from './ToolCallGroup';
|
||||
import { PlanDisplay } from './PlanView';
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButtons,
|
||||
} from '../ai-elements/conversation';
|
||||
|
||||
// =============================================================================
|
||||
// 统一聊天视图 — Anthropic 编辑式排版
|
||||
@@ -22,28 +27,25 @@ export function ChatView({
|
||||
entries,
|
||||
isLoading = false,
|
||||
onPermissionRespond,
|
||||
emptyTitle = "开始对话",
|
||||
emptyDescription = "输入消息开始聊天",
|
||||
emptyTitle = '开始对话',
|
||||
emptyDescription = '输入消息开始聊天',
|
||||
}: ChatViewProps) {
|
||||
// 将相邻的 ToolCallEntry 合并为一组
|
||||
const grouped = groupToolCalls(entries);
|
||||
const hasMessages = entries.length > 0;
|
||||
|
||||
// 检查是否正在加载(最后一个条目是用户消息)
|
||||
const showThinking = isLoading && entries.length > 0 && entries[entries.length - 1]?.type === "user_message";
|
||||
const showThinking = isLoading && entries.length > 0 && entries[entries.length - 1]?.type === 'user_message';
|
||||
|
||||
return (
|
||||
<Conversation className="flex-1">
|
||||
<ConversationContent>
|
||||
{!hasMessages ? (
|
||||
<ConversationEmptyState
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
<ConversationEmptyState title={emptyTitle} description={emptyDescription} />
|
||||
) : (
|
||||
<>
|
||||
{grouped.map((item, i) => {
|
||||
if (item.type === "single") {
|
||||
if (item.type === 'single') {
|
||||
return (
|
||||
<div key={`entry-${i}`} className={cn(entrySpacing(entries, i))}>
|
||||
<EntryRenderer entry={item.entry} isLoading={isLoading} onPermissionRespond={onPermissionRespond} />
|
||||
@@ -63,19 +65,25 @@ export function ChatView({
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
|
||||
<path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fill="var(--color-brand)"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 pt-2">
|
||||
<span className="chat-typing-indicator" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ConversationScrollButtons hasUserMessages={entries.some((e) => e.type === "user_message")} />
|
||||
<ConversationScrollButtons hasUserMessages={entries.some(e => e.type === 'user_message')} />
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
@@ -88,22 +96,22 @@ export function ChatView({
|
||||
function entrySpacing(entries: ThreadEntry[], index: number): string {
|
||||
const entry = entries[index];
|
||||
// 用户消息前后大留白 — Claude.ai 式宽松间距
|
||||
if (entry?.type === "user_message") {
|
||||
return "pt-10 pb-3";
|
||||
if (entry?.type === 'user_message') {
|
||||
return 'pt-10 pb-3';
|
||||
}
|
||||
// 助手消息 — 工具调用紧贴,否则多留白
|
||||
if (entry?.type === "assistant_message") {
|
||||
if (entry?.type === 'assistant_message') {
|
||||
const next = entries[index + 1];
|
||||
if (next?.type === "tool_call") {
|
||||
return "pt-3 pb-1";
|
||||
if (next?.type === 'tool_call') {
|
||||
return 'pt-3 pb-1';
|
||||
}
|
||||
return "pt-3 pb-8";
|
||||
return 'pt-3 pb-8';
|
||||
}
|
||||
// Plan 条目
|
||||
if (entry?.type === "plan") {
|
||||
return "pt-3 pb-3";
|
||||
if (entry?.type === 'plan') {
|
||||
return 'pt-3 pb-3';
|
||||
}
|
||||
return "py-2";
|
||||
return 'py-2';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -120,18 +128,13 @@ function EntryRenderer({
|
||||
onPermissionRespond?: (requestId: string, optionId: string | null, optionKind: string | null) => void;
|
||||
}) {
|
||||
switch (entry.type) {
|
||||
case "user_message":
|
||||
case 'user_message':
|
||||
return <UserBubble entry={entry} />;
|
||||
case "assistant_message":
|
||||
case 'assistant_message':
|
||||
return <AssistantBubble entry={entry} isStreaming={isLoading} />;
|
||||
case "tool_call":
|
||||
return (
|
||||
<ToolCallGroup
|
||||
entries={[entry as ToolCallEntry]}
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
);
|
||||
case "plan":
|
||||
case 'tool_call':
|
||||
return <ToolCallGroup entries={[entry as ToolCallEntry]} onPermissionRespond={onPermissionRespond} />;
|
||||
case 'plan':
|
||||
return <PlanDisplay entry={entry as PlanDisplayEntry} />;
|
||||
default:
|
||||
return null;
|
||||
@@ -142,9 +145,7 @@ function EntryRenderer({
|
||||
// 工具调用分组逻辑
|
||||
// =============================================================================
|
||||
|
||||
type GroupedItem =
|
||||
| { type: "single"; entry: ThreadEntry }
|
||||
| { type: "tool_group"; entries: ToolCallEntry[] };
|
||||
type GroupedItem = { type: 'single'; entry: ThreadEntry } | { type: 'tool_group'; entries: ToolCallEntry[] };
|
||||
|
||||
function groupToolCalls(entries: ThreadEntry[]): GroupedItem[] {
|
||||
const result: GroupedItem[] = [];
|
||||
@@ -152,19 +153,19 @@ function groupToolCalls(entries: ThreadEntry[]): GroupedItem[] {
|
||||
|
||||
const flushToolGroup = () => {
|
||||
if (currentToolGroup.length === 1) {
|
||||
result.push({ type: "single", entry: currentToolGroup[0] });
|
||||
result.push({ type: 'single', entry: currentToolGroup[0] });
|
||||
} else if (currentToolGroup.length > 1) {
|
||||
result.push({ type: "tool_group", entries: currentToolGroup });
|
||||
result.push({ type: 'tool_group', entries: currentToolGroup });
|
||||
}
|
||||
currentToolGroup = [];
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "tool_call") {
|
||||
if (entry.type === 'tool_call') {
|
||||
currentToolGroup.push(entry);
|
||||
} else {
|
||||
flushToolGroup();
|
||||
result.push({ type: "single", entry });
|
||||
result.push({ type: 'single', entry });
|
||||
}
|
||||
}
|
||||
flushToolGroup();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef, useEffect, useState } from "react";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { AvailableCommand } from "../../src/acp/types";
|
||||
import { useMemo, useRef, useEffect, useState } from 'react';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import type { AvailableCommand } from '../../src/acp/types';
|
||||
|
||||
// =============================================================================
|
||||
// Slash command picker — floating above ChatInput
|
||||
@@ -23,22 +23,14 @@ function prefixMatch(query: string, text: string): boolean {
|
||||
return text.toLowerCase().startsWith(query.toLowerCase());
|
||||
}
|
||||
|
||||
export function CommandMenu({
|
||||
commands,
|
||||
filter,
|
||||
onSelect,
|
||||
onClose,
|
||||
className,
|
||||
}: CommandMenuProps) {
|
||||
export function CommandMenu({ commands, filter, onSelect, onClose, className }: CommandMenuProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
// Filter commands by current input
|
||||
const filtered = useMemo(() => {
|
||||
if (!filter) return commands;
|
||||
return commands.filter(
|
||||
(cmd) => prefixMatch(filter, cmd.name),
|
||||
);
|
||||
return commands.filter(cmd => prefixMatch(filter, cmd.name));
|
||||
}, [commands, filter]);
|
||||
|
||||
// Reset active index when filter changes
|
||||
@@ -53,8 +45,8 @@ export function CommandMenu({
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [onClose]);
|
||||
|
||||
// Handle keyboard navigation (ArrowUp/ArrowDown/Enter) via document-level listener
|
||||
@@ -62,21 +54,21 @@ export function CommandMenu({
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (filtered.length === 0) return;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev + 1) % filtered.length);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
setActiveIndex(prev => (prev + 1) % filtered.length);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => (prev - 1 + filtered.length) % filtered.length);
|
||||
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||
setActiveIndex(prev => (prev - 1 + filtered.length) % filtered.length);
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const cmd = filtered[activeIndex];
|
||||
if (cmd) onSelect(cmd);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown, true); // capture phase
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, true);
|
||||
document.addEventListener('keydown', handleKeyDown, true); // capture phase
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [filtered, activeIndex, onSelect]);
|
||||
|
||||
// Scroll active item into view
|
||||
@@ -84,22 +76,14 @@ export function CommandMenu({
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const active = container.querySelector("[data-active='true']");
|
||||
active?.scrollIntoView({ block: "nearest" });
|
||||
active?.scrollIntoView({ block: 'nearest' });
|
||||
}, [activeIndex]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"rounded-xl border border-border bg-surface-2 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div ref={containerRef} className={cn('rounded-xl border border-border bg-surface-2 shadow-lg', className)}>
|
||||
<div className="max-h-[320px] overflow-y-auto py-1">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-xs text-text-muted font-display py-3 text-center">
|
||||
没有匹配的命令
|
||||
</div>
|
||||
<div className="text-xs text-text-muted font-display py-3 text-center">没有匹配的命令</div>
|
||||
) : (
|
||||
filtered.map((cmd, index) => (
|
||||
<button
|
||||
@@ -109,25 +93,15 @@ export function CommandMenu({
|
||||
onClick={() => onSelect(cmd)}
|
||||
onMouseEnter={() => setActiveIndex(index)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-3 py-2 cursor-pointer rounded-lg mx-1 text-left",
|
||||
"transition-colors",
|
||||
index === activeIndex
|
||||
? "bg-brand/10 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-1/50",
|
||||
'flex w-full items-center gap-2 px-3 py-2 cursor-pointer rounded-lg mx-1 text-left',
|
||||
'transition-colors',
|
||||
index === activeIndex ? 'bg-brand/10 text-text-primary' : 'text-text-secondary hover:bg-surface-1/50',
|
||||
)}
|
||||
style={{ width: "calc(100% - 8px)" }}
|
||||
style={{ width: 'calc(100% - 8px)' }}
|
||||
>
|
||||
<span className="text-sm font-display font-medium text-brand">
|
||||
/{cmd.name}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted truncate flex-1">
|
||||
{cmd.description}
|
||||
</span>
|
||||
{cmd.input?.hint && (
|
||||
<span className="text-[10px] text-text-muted italic">
|
||||
{cmd.input.hint}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-display font-medium text-brand">/{cmd.name}</span>
|
||||
<span className="text-xs text-text-muted truncate flex-1">{cmd.description}</span>
|
||||
{cmd.input?.hint && <span className="text-[10px] text-text-muted italic">{cmd.input.hint}</span>}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import type { UserMessageEntry, AssistantMessageEntry, UserMessageImage } from "../../src/lib/types";
|
||||
import { cn, esc } from "../../src/lib/utils";
|
||||
import { MessageResponse } from "../ai-elements/message";
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from "../ai-elements/reasoning";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import type { UserMessageEntry, AssistantMessageEntry, UserMessageImage } from '../../src/lib/types';
|
||||
import { cn, esc } from '../../src/lib/utils';
|
||||
import { MessageResponse } from '../ai-elements/message';
|
||||
import { Reasoning, ReasoningTrigger, ReasoningContent } from '../ai-elements/reasoning';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
|
||||
// 用户消息折叠最大高度(px)
|
||||
const COLLAPSED_MAX_HEIGHT = 200;
|
||||
@@ -48,7 +48,7 @@ export function UserBubble({ entry }: UserBubbleProps) {
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn(
|
||||
"px-5 py-3 text-sm text-white whitespace-pre-wrap font-display leading-relaxed",
|
||||
'px-5 py-3 text-sm text-white whitespace-pre-wrap font-display leading-relaxed',
|
||||
!expanded && overflowing && `max-h-[${COLLAPSED_MAX_HEIGHT}px]`,
|
||||
)}
|
||||
style={!expanded && overflowing ? { maxHeight: `${COLLAPSED_MAX_HEIGHT}px` } : undefined}
|
||||
@@ -90,7 +90,11 @@ export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
{/* Orange triangle avatar */}
|
||||
<div className="w-8 h-8 rounded-lg bg-brand/8 flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="var(--color-brand)" fillRule="nonzero" />
|
||||
<path
|
||||
d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"
|
||||
fill="var(--color-brand)"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/* 内容 — 无卡片背景,直接排版 */}
|
||||
@@ -98,16 +102,14 @@ export function AssistantBubble({ entry, isStreaming }: AssistantBubbleProps) {
|
||||
{/* Sender label */}
|
||||
<span className="text-sm font-semibold text-text-primary font-display">Claude</span>
|
||||
{entry.chunks.map((chunk, i) => {
|
||||
if (chunk.type === "thought") {
|
||||
if (chunk.type === 'thought') {
|
||||
const isLastChunk = i === entry.chunks.length - 1;
|
||||
const isThoughtStreaming = isStreaming && isLastChunk;
|
||||
return (
|
||||
<Reasoning key={i} isStreaming={isThoughtStreaming}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>
|
||||
<div className="text-sm text-text-secondary leading-relaxed">
|
||||
{chunk.text}
|
||||
</div>
|
||||
<div className="text-sm text-text-secondary leading-relaxed">{chunk.text}</div>
|
||||
</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
@@ -135,17 +137,13 @@ function ImageThumbnail({ image }: { image: UserMessageImage }) {
|
||||
type="button"
|
||||
className="rounded-lg overflow-hidden border border-border hover:border-brand/40 transition-colors cursor-pointer"
|
||||
onClick={() => {
|
||||
const w = window.open("");
|
||||
const w = window.open('');
|
||||
if (w) {
|
||||
w.document.write(`<img src="${dataUrl}" style="max-width:100%;max-height:100%" />`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt="Uploaded image"
|
||||
className="h-20 w-20 object-cover"
|
||||
/>
|
||||
<img src={dataUrl} alt="Uploaded image" className="h-20 w-20 object-cover" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PendingPermission } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ShieldAlert, Check, X } from "lucide-react";
|
||||
import type { PendingPermission } from '../../src/lib/types';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { ShieldAlert, Check, X } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// 权限请求面板 — 固定在输入框上方(Anthropic warm token style)
|
||||
@@ -16,14 +16,10 @@ export function PermissionPanel({ requests, onRespond, className }: PermissionPa
|
||||
if (requests.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full max-w-3xl mx-auto px-4", className)}>
|
||||
<div className={cn('w-full max-w-3xl mx-auto px-4', className)}>
|
||||
<div className="space-y-2">
|
||||
{requests.map((req) => (
|
||||
<PermissionCard
|
||||
key={req.requestId}
|
||||
request={req}
|
||||
onRespond={onRespond}
|
||||
/>
|
||||
{requests.map(req => (
|
||||
<PermissionCard key={req.requestId} request={req} onRespond={onRespond} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,13 +40,9 @@ function PermissionCard({ request, onRespond }: PermissionCardProps) {
|
||||
<div className="flex items-center gap-3 rounded-xl border border-warning-border/30 bg-warning-bg/50 px-4 py-3">
|
||||
<ShieldAlert className="h-5 w-5 text-warning-text flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-warning-text">
|
||||
{request.toolName}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-warning-text">{request.toolName}</div>
|
||||
{request.description && (
|
||||
<div className="text-xs text-warning-text/80 mt-0.5 truncate">
|
||||
{request.description}
|
||||
</div>
|
||||
<div className="text-xs text-warning-text/80 mt-0.5 truncate">{request.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import type { PlanDisplayEntry } from "../../src/lib/types";
|
||||
import type { PlanEntry, PlanEntryPriority, PlanEntryStatus } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { CheckCircle2, Loader2, Circle } from "lucide-react";
|
||||
import { useState } from 'react';
|
||||
import type { PlanDisplayEntry } from '../../src/lib/types';
|
||||
import type { PlanEntry, PlanEntryPriority, PlanEntryStatus } from '../../src/acp/types';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { CheckCircle2, Loader2, Circle } from 'lucide-react';
|
||||
|
||||
// =============================================================================
|
||||
// Plan 展示组件 — 执行计划可视化
|
||||
@@ -18,7 +18,7 @@ export function PlanDisplay({ entry }: PlanDisplayProps) {
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
const completed = entries.filter((e) => e.status === "completed").length;
|
||||
const completed = entries.filter(e => e.status === 'completed').length;
|
||||
const total = entries.length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
@@ -36,14 +36,12 @@ export function PlanDisplay({ entry }: PlanDisplayProps) {
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={cn("transition-transform text-text-muted flex-shrink-0", collapsed && "rotate-90")}
|
||||
className={cn('transition-transform text-text-muted flex-shrink-0', collapsed && 'rotate-90')}
|
||||
>
|
||||
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
|
||||
<span className="text-xs font-display font-medium text-text-secondary">
|
||||
执行计划
|
||||
</span>
|
||||
<span className="text-xs font-display font-medium text-text-secondary">执行计划</span>
|
||||
|
||||
<span className="text-[10px] text-text-muted font-mono">
|
||||
{completed}/{total}
|
||||
@@ -57,17 +55,14 @@ export function PlanDisplay({ entry }: PlanDisplayProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="text-[10px] text-text-muted font-mono">
|
||||
{percentage}%
|
||||
</span>
|
||||
<span className="text-[10px] text-text-muted font-mono">{percentage}%</span>
|
||||
</button>
|
||||
|
||||
{/* Entry list */}
|
||||
{!collapsed && (
|
||||
<div className={cn(
|
||||
"border-t border-border px-3 py-1.5 space-y-0.5",
|
||||
total > 5 && "max-h-64 overflow-y-auto",
|
||||
)}>
|
||||
<div
|
||||
className={cn('border-t border-border px-3 py-1.5 space-y-0.5', total > 5 && 'max-h-64 overflow-y-auto')}
|
||||
>
|
||||
{entries.map((planEntry, i) => (
|
||||
<PlanEntryRow key={i} entry={planEntry} />
|
||||
))}
|
||||
@@ -88,11 +83,13 @@ function PlanEntryRow({ entry }: { entry: PlanEntry }) {
|
||||
<span className="flex-shrink-0 mt-0.5">
|
||||
<StatusIcon status={entry.status} />
|
||||
</span>
|
||||
<span className={cn(
|
||||
"text-xs leading-relaxed flex-1",
|
||||
entry.status === "completed" ? "text-text-muted line-through" : "text-text-secondary",
|
||||
entry.status === "in_progress" && "text-text-primary font-medium",
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs leading-relaxed flex-1',
|
||||
entry.status === 'completed' ? 'text-text-muted line-through' : 'text-text-secondary',
|
||||
entry.status === 'in_progress' && 'text-text-primary font-medium',
|
||||
)}
|
||||
>
|
||||
{entry.content}
|
||||
</span>
|
||||
<PriorityBadge priority={entry.priority} />
|
||||
@@ -106,11 +103,11 @@ function PlanEntryRow({ entry }: { entry: PlanEntry }) {
|
||||
|
||||
function StatusIcon({ status }: { status: PlanEntryStatus }) {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-3.5 w-3.5 text-status-active" />;
|
||||
case "in_progress":
|
||||
return <Loader2 className="h-3.5 w-3.5 text-brand animate-spin" style={{ animationDuration: "2s" }} />;
|
||||
case "pending":
|
||||
case 'in_progress':
|
||||
return <Loader2 className="h-3.5 w-3.5 text-brand animate-spin" style={{ animationDuration: '2s' }} />;
|
||||
case 'pending':
|
||||
return <Circle className="h-3.5 w-3.5 text-text-muted" />;
|
||||
}
|
||||
}
|
||||
@@ -121,22 +118,21 @@ function StatusIcon({ status }: { status: PlanEntryStatus }) {
|
||||
|
||||
function PriorityBadge({ priority }: { priority: PlanEntryPriority }) {
|
||||
const styles: Record<PlanEntryPriority, string> = {
|
||||
high: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
medium: "bg-brand/10 text-brand dark:bg-brand/20",
|
||||
low: "bg-surface-1 text-text-muted",
|
||||
high: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300',
|
||||
medium: 'bg-brand/10 text-brand dark:bg-brand/20',
|
||||
low: 'bg-surface-1 text-text-muted',
|
||||
};
|
||||
|
||||
const labels: Record<PlanEntryPriority, string> = {
|
||||
high: "高",
|
||||
medium: "中",
|
||||
low: "低",
|
||||
high: '高',
|
||||
medium: '中',
|
||||
low: '低',
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn(
|
||||
"text-[9px] font-display rounded-full px-1.5 py-0.5 flex-shrink-0 leading-none",
|
||||
styles[priority],
|
||||
)}>
|
||||
<span
|
||||
className={cn('text-[9px] font-display rounded-full px-1.5 py-0.5 flex-shrink-0 leading-none', styles[priority])}
|
||||
>
|
||||
{labels[priority]}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { Plus, MessageSquare, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import type { SessionListItem } from "../../src/lib/types";
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { Plus, MessageSquare, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import type { SessionListItem } from '../../src/lib/types';
|
||||
|
||||
// =============================================================================
|
||||
// 会话侧边栏 — Anthropic 分段式:今天/昨天/更早 + 橙色活跃态
|
||||
@@ -15,13 +15,7 @@ interface SessionSidebarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SessionSidebar({
|
||||
sessions,
|
||||
activeId,
|
||||
onSelect,
|
||||
onNew,
|
||||
className,
|
||||
}: SessionSidebarProps) {
|
||||
export function SessionSidebar({ sessions, activeId, onSelect, onNew, className }: SessionSidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// 按日期分组
|
||||
@@ -30,8 +24,8 @@ export function SessionSidebar({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200",
|
||||
collapsed ? "w-12" : "w-64",
|
||||
'hidden md:flex flex-col border-r border-border bg-surface-1 transition-all duration-200',
|
||||
collapsed ? 'w-12' : 'w-64',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -55,11 +49,7 @@ export function SessionSidebar({
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="h-7 w-7 flex items-center justify-center rounded-lg text-text-muted hover:text-text-primary hover:bg-surface-2 transition-colors"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
)}
|
||||
{collapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -67,30 +57,28 @@ export function SessionSidebar({
|
||||
{/* 会话列表 — 分段 */}
|
||||
{!collapsed && (
|
||||
<nav className="flex-1 overflow-y-auto py-2" aria-label="历史会话">
|
||||
{groups.map((group) => (
|
||||
{groups.map(group => (
|
||||
<div key={group.label}>
|
||||
<div className="px-3 py-1.5">
|
||||
<span className="text-[10px] font-display font-medium uppercase tracking-widest text-text-muted">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
{group.sessions.map((session) => (
|
||||
{group.sessions.map(session => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => onSelect?.(session.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors",
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-left transition-colors',
|
||||
session.id === activeId
|
||||
? "bg-brand/10 text-text-primary"
|
||||
: "text-text-secondary hover:bg-surface-1/50 hover:text-text-primary",
|
||||
? 'bg-brand/10 text-text-primary'
|
||||
: 'text-text-secondary hover:bg-surface-1/50 hover:text-text-primary',
|
||||
)}
|
||||
title={session.title || session.id}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-text-muted" />
|
||||
<span className="text-sm font-display truncate">
|
||||
{session.title || session.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="text-sm font-display truncate">{session.title || session.id.slice(0, 8)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -121,9 +109,9 @@ function groupByRecency(sessions: SessionListItem[]): SessionGroup[] {
|
||||
const yesterday = new Date(today.getTime() - 86400000);
|
||||
|
||||
const groups: SessionGroup[] = [
|
||||
{ label: "今天", sessions: [] },
|
||||
{ label: "昨天", sessions: [] },
|
||||
{ label: "更早", sessions: [] },
|
||||
{ label: '今天', sessions: [] },
|
||||
{ label: '昨天', sessions: [] },
|
||||
{ label: '更早', sessions: [] },
|
||||
];
|
||||
|
||||
for (const session of sessions) {
|
||||
@@ -137,5 +125,5 @@ function groupByRecency(sessions: SessionListItem[]): SessionGroup[] {
|
||||
}
|
||||
}
|
||||
|
||||
return groups.filter((g) => g.sessions.length > 0);
|
||||
return groups.filter(g => g.sessions.length > 0);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolCallEntry, ToolCallData } from "../../src/lib/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { ToolPermissionButtons } from "../ai-elements/permission-request";
|
||||
import { useState } from 'react';
|
||||
import type { ToolCallEntry, ToolCallData } from '../../src/lib/types';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { ToolPermissionButtons } from '../ai-elements/permission-request';
|
||||
|
||||
// =============================================================================
|
||||
// 工具调用折叠组 — Anthropic: subtle card, left-border accent, compact layout
|
||||
@@ -21,11 +21,7 @@ export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupPro
|
||||
if (entries.length === 1) {
|
||||
return (
|
||||
<div className="pl-10">
|
||||
<SingleToolCard
|
||||
tool={entries[0].toolCall}
|
||||
compact
|
||||
onPermissionRespond={onPermissionRespond}
|
||||
/>
|
||||
<SingleToolCard tool={entries[0].toolCall} compact onPermissionRespond={onPermissionRespond} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +43,7 @@ export function ToolCallGroup({ entries, onPermissionRespond }: ToolCallGroupPro
|
||||
height="12"
|
||||
viewBox="0 0 12 12"
|
||||
fill="none"
|
||||
className={cn("transition-transform text-text-muted", expanded && "rotate-90")}
|
||||
className={cn('transition-transform text-text-muted', expanded && 'rotate-90')}
|
||||
>
|
||||
<path d="M4 2L8 6L4 10" stroke="currentColor" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
@@ -87,27 +83,28 @@ function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardPr
|
||||
|
||||
const statusIcon = (() => {
|
||||
switch (tool.status) {
|
||||
case "running":
|
||||
case 'running':
|
||||
return <span className="text-status-running text-[10px]">▶</span>;
|
||||
case "complete":
|
||||
case 'complete':
|
||||
return <span className="text-status-active text-[10px]">✓</span>;
|
||||
case "error":
|
||||
case 'error':
|
||||
return <span className="text-status-error text-[10px]">✕</span>;
|
||||
case "waiting_for_confirmation":
|
||||
case 'waiting_for_confirmation':
|
||||
return <span className="text-brand text-[10px]">⍻</span>;
|
||||
case "canceled":
|
||||
case 'canceled':
|
||||
return <span className="text-text-muted text-[10px]">—</span>;
|
||||
case "rejected":
|
||||
case 'rejected':
|
||||
return <span className="text-status-error text-[10px]">✕</span>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const hasOutput = tool.status !== "running" && tool.status !== "waiting_for_confirmation" && (tool.rawOutput || tool.content);
|
||||
const hasOutput =
|
||||
tool.status !== 'running' && tool.status !== 'waiting_for_confirmation' && (tool.rawOutput || tool.content);
|
||||
|
||||
return (
|
||||
<div className={cn("px-3 py-2", compact && "py-1.5")}>
|
||||
<div className={cn('px-3 py-2', compact && 'py-1.5')}>
|
||||
{/* 标题行 — 单行紧凑 */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -118,13 +115,11 @@ function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardPr
|
||||
<span className="text-xs font-display font-medium text-text-secondary group-hover:text-text-primary transition-colors truncate">
|
||||
{tool.title}
|
||||
</span>
|
||||
{tool.status === "running" && (
|
||||
<span className="text-[10px] text-status-running animate-pulse">running</span>
|
||||
)}
|
||||
{tool.status === 'running' && <span className="text-[10px] text-status-running animate-pulse">running</span>}
|
||||
</button>
|
||||
|
||||
{/* 权限请求按钮 */}
|
||||
{tool.status === "waiting_for_confirmation" && tool.permissionRequest && (
|
||||
{tool.status === 'waiting_for_confirmation' && tool.permissionRequest && (
|
||||
<div className="mt-1.5 ml-4">
|
||||
<ToolPermissionButtons
|
||||
requestId={tool.permissionRequest.requestId}
|
||||
@@ -146,10 +141,12 @@ function SingleToolCard({ tool, compact, onPermissionRespond }: SingleToolCardPr
|
||||
)}
|
||||
{hasOutput && (
|
||||
<div>
|
||||
<pre className={cn(
|
||||
"text-[11px] rounded-md p-2 overflow-x-auto font-mono max-h-36",
|
||||
tool.status === "error" ? "bg-status-error/10 text-status-error" : "bg-surface-1 text-text-secondary",
|
||||
)}>
|
||||
<pre
|
||||
className={cn(
|
||||
'text-[11px] rounded-md p-2 overflow-x-auto font-mono max-h-36',
|
||||
tool.status === 'error' ? 'bg-status-error/10 text-status-error' : 'bg-surface-1 text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{formatOutput(tool)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -179,7 +176,7 @@ function buildSummary(entries: ToolCallEntry[]): string {
|
||||
|
||||
if (parts.length === 0) return `${entries.length} 个工具调用`;
|
||||
if (parts.length === 1) return parts[0];
|
||||
return `${entries.length} 个工具: ${parts.join("、")}`;
|
||||
return `${entries.length} 个工具: ${parts.join('、')}`;
|
||||
}
|
||||
|
||||
/** 简化工具名称 */
|
||||
@@ -192,17 +189,17 @@ function simplifyToolName(title: string): string {
|
||||
function formatOutput(tool: ToolCallData): string {
|
||||
if (tool.content && tool.content.length > 0) {
|
||||
const texts = tool.content
|
||||
.filter((c): c is Extract<typeof c, { type: "content" }> => c.type === "content")
|
||||
.filter((c) => c.content.type === "text" && "text" in c.content)
|
||||
.map((c) => (c.content as { text: string }).text);
|
||||
if (texts.length > 0) return truncate(texts.join("\n"), 2000);
|
||||
.filter((c): c is Extract<typeof c, { type: 'content' }> => c.type === 'content')
|
||||
.filter(c => c.content.type === 'text' && 'text' in c.content)
|
||||
.map(c => (c.content as { text: string }).text);
|
||||
if (texts.length > 0) return truncate(texts.join('\n'), 2000);
|
||||
}
|
||||
if (tool.rawOutput && Object.keys(tool.rawOutput).length > 0) {
|
||||
return truncate(JSON.stringify(tool.rawOutput, null, 2), 2000);
|
||||
}
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
|
||||
function truncate(str: string, max: number): string {
|
||||
return str.length > max ? str.slice(0, max) + "..." : str;
|
||||
return str.length > max ? str.slice(0, max) + '...' : str;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export { ChatView } from "./ChatView";
|
||||
export { UserBubble, AssistantBubble } from "./MessageBubble";
|
||||
export { ToolCallGroup } from "./ToolCallGroup";
|
||||
export { PlanDisplay } from "./PlanView";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { PermissionPanel } from "./PermissionPanel";
|
||||
export { SessionSidebar } from "./SessionSidebar";
|
||||
export { CommandMenu } from "./CommandMenu";
|
||||
export { ChatView } from './ChatView'
|
||||
export { UserBubble, AssistantBubble } from './MessageBubble'
|
||||
export { ToolCallGroup } from './ToolCallGroup'
|
||||
export { PlanDisplay } from './PlanView'
|
||||
export { ChatInput } from './ChatInput'
|
||||
export { PermissionPanel } from './PermissionPanel'
|
||||
export { SessionSidebar } from './SessionSidebar'
|
||||
export { CommandMenu } from './CommandMenu'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * from "./ACPConnect";
|
||||
export * from "./ACPMain";
|
||||
export * from "./ChatInterface";
|
||||
export * from "./ChatMessage";
|
||||
export * from "./ThreadHistory";
|
||||
export * from "./model-selector";
|
||||
export * from './ACPConnect'
|
||||
export * from './ACPMain'
|
||||
export * from './ChatInterface'
|
||||
export * from './ChatMessage'
|
||||
export * from './ThreadHistory'
|
||||
export * from './model-selector'
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
} from "../ui/command";
|
||||
import type { ModelInfo } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from '../ui/command';
|
||||
import type { ModelInfo } from '../../src/acp/types';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
interface ModelSelectorPickerProps {
|
||||
models: ModelInfo[];
|
||||
@@ -51,33 +44,24 @@ export function ModelSelectorPicker({
|
||||
showSearch = true,
|
||||
isMobile = false,
|
||||
}: ModelSelectorPickerProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [search, setSearch] = useState('');
|
||||
// On mobile, don't auto-select first item (no keyboard navigation needed)
|
||||
// Use a non-existent value to prevent any item from being selected
|
||||
const [selectedValue, setSelectedValue] = useState(isMobile ? "__none__" : undefined);
|
||||
const [selectedValue, setSelectedValue] = useState(isMobile ? '__none__' : undefined);
|
||||
|
||||
// Filter models using fuzzy search
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!search) return models;
|
||||
return models.filter((model) =>
|
||||
fuzzyMatch(search, model.name) ||
|
||||
fuzzyMatch(search, model.modelId)
|
||||
);
|
||||
return models.filter(model => fuzzyMatch(search, model.name) || fuzzyMatch(search, model.modelId));
|
||||
}, [models, search]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false} value={selectedValue} onValueChange={setSelectedValue}>
|
||||
{showSearch && (
|
||||
<CommandInput
|
||||
placeholder="Select a model…"
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
)}
|
||||
{showSearch && <CommandInput placeholder="Select a model…" value={search} onValueChange={setSearch} />}
|
||||
<CommandList>
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredModels.map((model) => (
|
||||
{filteredModels.map(model => (
|
||||
<CommandItem
|
||||
key={model.modelId}
|
||||
value={model.modelId}
|
||||
@@ -87,16 +71,11 @@ export function ModelSelectorPicker({
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="truncate font-medium">{model.name}</span>
|
||||
{model.description && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{model.description}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{model.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
currentModelId === model.modelId ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
className={cn('h-4 w-4 shrink-0', currentModelId === model.modelId ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
@@ -105,4 +84,3 @@ export function ModelSelectorPicker({
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronUp, Loader2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { ModelSelectorPicker } from "./ModelSelectorPicker";
|
||||
import type { ACPClient } from "../../src/acp/client";
|
||||
import type { ModelInfo } from "../../src/acp/types";
|
||||
import { useModels } from "../../src/hooks/useModels";
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ModelSelectorPicker } from './ModelSelectorPicker';
|
||||
import type { ACPClient } from '../../src/acp/client';
|
||||
import type { ModelInfo } from '../../src/acp/types';
|
||||
import { useModels } from '../../src/hooks/useModels';
|
||||
|
||||
interface ModelSelectorPopoverProps {
|
||||
/** ACPClient instance for model state management */
|
||||
@@ -18,25 +18,15 @@ interface ModelSelectorPopoverProps {
|
||||
* Model selector popover component.
|
||||
* Reference: Zed's AcpModelSelectorPopover that shows current model and allows switching.
|
||||
*/
|
||||
export function ModelSelectorPopover({
|
||||
client,
|
||||
onModelSelect,
|
||||
}: ModelSelectorPopoverProps) {
|
||||
export function ModelSelectorPopover({ client, onModelSelect }: ModelSelectorPopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const {
|
||||
supportsModelSelection,
|
||||
availableModels,
|
||||
currentModel,
|
||||
setModel,
|
||||
isLoading,
|
||||
} = useModels(client);
|
||||
const { supportsModelSelection, availableModels, currentModel, setModel, isLoading } = useModels(client);
|
||||
|
||||
// Always show the button — disable dropdown when no models available
|
||||
const hasModels = supportsModelSelection && availableModels.length > 0;
|
||||
|
||||
// Check if we're on a mobile device (touch-only)
|
||||
const isMobile = typeof window !== "undefined" &&
|
||||
window.matchMedia("(hover: none) and (pointer: coarse)").matches;
|
||||
const isMobile = typeof window !== 'undefined' && window.matchMedia('(hover: none) and (pointer: coarse)').matches;
|
||||
|
||||
const handleSelect = async (model: ModelInfo) => {
|
||||
try {
|
||||
@@ -44,7 +34,7 @@ export function ModelSelectorPopover({
|
||||
onModelSelect?.(model.modelId);
|
||||
setOpen(false);
|
||||
} catch (error) {
|
||||
console.error("[ModelSelector] Failed to set model:", error);
|
||||
console.error('[ModelSelector] Failed to set model:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -57,17 +47,9 @@ export function ModelSelectorPopover({
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground h-7 px-2"
|
||||
disabled={!hasModels || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : null}
|
||||
<span className="max-w-32 truncate">
|
||||
{currentModel?.name ?? "Select Model"}
|
||||
</span>
|
||||
{open ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
{isLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
<span className="max-w-32 truncate">{currentModel?.name ?? 'Select Model'}</span>
|
||||
{open ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0" align="end">
|
||||
@@ -82,4 +64,3 @@ export function ModelSelectorPopover({
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { ModelSelectorPopover } from "./ModelSelectorPopover";
|
||||
export { ModelSelectorPicker } from "./ModelSelectorPicker";
|
||||
|
||||
export { ModelSelectorPopover } from './ModelSelectorPopover'
|
||||
export { ModelSelectorPicker } from './ModelSelectorPicker'
|
||||
|
||||
@@ -1,47 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span"
|
||||
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { Separator } from "./separator"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { Separator } from './separator';
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
@@ -10,22 +10,22 @@ const buttonGroupVariants = cva(
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
'[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
orientation: 'horizontal',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
@@ -34,51 +34,42 @@ function ButtonGroup({
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}: React.ComponentProps<'div'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
const Comp = asChild ? Slot : 'div';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
className={cn('bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
);
|
||||
}
|
||||
|
||||
export { ButtonGroup, ButtonGroupSeparator, ButtonGroupText, buttonGroupVariants };
|
||||
|
||||
@@ -1,40 +1,38 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8',
|
||||
'icon-lg': 'size-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
@@ -42,20 +40,13 @@ function Button({
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,93 +1,56 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className
|
||||
)}
|
||||
className={cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-title" className={cn('leading-none font-semibold', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-description" className={cn('text-muted-foreground text-sm', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
className={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
<div data-slot="card-footer" className={cn('flex items-center px-6 [.border-t]:pt-6', className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
function Collapsible({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function CollapsibleTrigger({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return <CollapsiblePrimitive.CollapsibleTrigger data-slot="collapsible-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function CollapsibleContent({ ...props }: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return <CollapsiblePrimitive.CollapsibleContent data-slot="collapsible-content" {...props} />;
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
|
||||
@@ -1,44 +1,35 @@
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { SearchIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./dialog"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './dialog';
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
'bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
title = 'Command Palette',
|
||||
description = 'Search for a command to run...',
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
@@ -46,127 +37,89 @@ function CommandDialog({
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<DialogContent className={cn('overflow-hidden p-0', className)} showCloseButton={showCloseButton}>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
className={cn('max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />;
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
'text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
className={cn('bg-border -mx-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
function CommandShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -179,5 +132,4 @@ export {
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import type { ConnectionState } from "../../src/acp/types";
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import type { ConnectionState } from '../../src/acp/types';
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
// Shared styles for connection state dots
|
||||
const connectionDotStyles: Record<ConnectionState, string> = {
|
||||
disconnected: "bg-gray-400",
|
||||
connecting: "bg-yellow-400 animate-pulse",
|
||||
connected: "bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]",
|
||||
error: "bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]",
|
||||
disconnected: 'bg-gray-400',
|
||||
connecting: 'bg-yellow-400 animate-pulse',
|
||||
connected: 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]',
|
||||
error: 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]',
|
||||
};
|
||||
|
||||
// Shared labels for connection states
|
||||
const connectionStateLabels: Record<ConnectionState, string> = {
|
||||
disconnected: "Disconnected",
|
||||
connecting: "Connecting...",
|
||||
connected: "Connected",
|
||||
error: "Error",
|
||||
disconnected: 'Disconnected',
|
||||
connecting: 'Connecting...',
|
||||
connected: 'Connected',
|
||||
error: 'Error',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -28,33 +28,17 @@ export function getConnectionStateLabel(state: ConnectionState): string {
|
||||
* A small dot indicator for connection state
|
||||
* Used in status bars and headers
|
||||
*/
|
||||
export function StatusDot({
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn("w-2 h-2 rounded-full", connectionDotStyles[state], className)}
|
||||
/>
|
||||
);
|
||||
export function StatusDot({ state, className }: { state: ConnectionState; className?: string }) {
|
||||
return <span className={cn('w-2 h-2 rounded-full', connectionDotStyles[state], className)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* A status indicator with dot and label
|
||||
* Used in cards and detailed views
|
||||
*/
|
||||
export function StatusIndicator({
|
||||
state,
|
||||
className,
|
||||
}: {
|
||||
state: ConnectionState;
|
||||
className?: string;
|
||||
}) {
|
||||
export function StatusIndicator({ state, className }: { state: ConnectionState; className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-2 text-sm font-normal", className)}>
|
||||
<span className={cn('flex items-center gap-2 text-sm font-normal', className)}>
|
||||
<StatusDot state={state} />
|
||||
{state}
|
||||
</span>
|
||||
@@ -74,17 +58,12 @@ export function ConnectionStatusBar({
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<StatusDot state={state} />
|
||||
<span className="text-sm font-medium">
|
||||
{getConnectionStateLabel(state)}
|
||||
</span>
|
||||
{state === "connected" && displayUrl && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{displayUrl}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{getConnectionStateLabel(state)}</span>
|
||||
{state === 'connected' && displayUrl && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[150px]">{displayUrl}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,38 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@@ -52,7 +41,7 @@ function DialogContent({
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
@@ -60,8 +49,8 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -77,56 +66,47 @@ function DialogContent({
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -140,5 +120,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -1,32 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
@@ -40,31 +27,27 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
@@ -73,11 +56,11 @@ function DropdownMenuItem({
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
@@ -91,7 +74,7 @@ function DropdownMenuCheckboxItem({
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
@@ -103,18 +86,11 @@ function DropdownMenuCheckboxItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
@@ -127,7 +103,7 @@ function DropdownMenuRadioItem({
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -138,7 +114,7 @@ function DropdownMenuRadioItem({
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
@@ -146,54 +122,40 @@ function DropdownMenuLabel({
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
className={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
className={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
@@ -202,7 +164,7 @@ function DropdownMenuSubTrigger({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
@@ -210,14 +172,14 @@ function DropdownMenuSubTrigger({
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
@@ -228,12 +190,12 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -252,5 +214,4 @@ export {
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
||||
import * as React from 'react';
|
||||
import * as HoverCardPrimitive from '@radix-ui/react-hover-card';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function HoverCard({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
function HoverCard({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
function HoverCardTrigger({ ...props }: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return <HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
@@ -32,14 +26,13 @@ function HoverCardContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
export * from "./badge"
|
||||
export * from "./connection-status"
|
||||
export * from "./button-group"
|
||||
export * from "./button"
|
||||
export * from "./card"
|
||||
export * from "./collapsible"
|
||||
export * from "./command"
|
||||
export * from "./dialog"
|
||||
export * from "./dropdown-menu"
|
||||
export * from "./hover-card"
|
||||
export * from "./input-group"
|
||||
export * from "./input"
|
||||
export * from "./label"
|
||||
export * from "./resizable"
|
||||
export * from "./scroll-area"
|
||||
export * from "./select"
|
||||
export * from "./separator"
|
||||
export * from "./tabs"
|
||||
export * from "./textarea"
|
||||
export * from "./theme-toggle"
|
||||
export * from "./tooltip"
|
||||
export * from "./popover"
|
||||
export * from './badge'
|
||||
export * from './connection-status'
|
||||
export * from './button-group'
|
||||
export * from './button'
|
||||
export * from './card'
|
||||
export * from './collapsible'
|
||||
export * from './command'
|
||||
export * from './dialog'
|
||||
export * from './dropdown-menu'
|
||||
export * from './hover-card'
|
||||
export * from './input-group'
|
||||
export * from './input'
|
||||
export * from './label'
|
||||
export * from './resizable'
|
||||
export * from './scroll-area'
|
||||
export * from './select'
|
||||
export * from './separator'
|
||||
export * from './tabs'
|
||||
export * from './textarea'
|
||||
export * from './theme-toggle'
|
||||
export * from './tooltip'
|
||||
export * from './popover'
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { Button } from "./button"
|
||||
import { Input } from "./input"
|
||||
import { Textarea } from "./textarea"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { Textarea } from './textarea';
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
||||
'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'h-9 min-w-0 has-[>textarea]:h-auto',
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
'has-[>[data-align=inline-start]]:[&>input]:pl-2',
|
||||
'has-[>[data-align=inline-end]]:[&>input]:pr-2',
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
'has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
@@ -41,70 +41,62 @@ const inputGroupAddonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
||||
'inline-start': 'order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]',
|
||||
'inline-end': 'order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]',
|
||||
'block-start':
|
||||
'order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5',
|
||||
'block-end': 'order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
align: 'inline-start',
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
align = 'inline-start',
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
onClick={e => {
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return;
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
e.currentTarget.parentElement?.querySelector('input')?.focus();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"text-sm shadow-none flex gap-2 items-center",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
const inputGroupButtonVariants = cva('text-sm shadow-none flex gap-2 items-center', {
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
||||
sm: 'h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5',
|
||||
'icon-xs': 'size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0',
|
||||
'icon-sm': 'size-8 p-0 has-[>svg]:p-0',
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'xs',
|
||||
},
|
||||
});
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
type = 'button',
|
||||
variant = 'ghost',
|
||||
size = 'xs',
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
}: Omit<React.ComponentProps<typeof Button>, 'size'> & VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
@@ -113,59 +105,45 @@ function InputGroupButton({
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
function InputGroupInput({ className, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
'flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
function InputGroupTextarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
'flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
);
|
||||
}
|
||||
|
||||
export { InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea };
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
import * as React from "react"
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
||||
export { Input };
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from "../../src/lib/utils";
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -19,4 +19,3 @@ function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimiti
|
||||
}
|
||||
|
||||
export { Label };
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
@@ -28,20 +24,17 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
import { GripVerticalIcon } from 'lucide-react';
|
||||
import * as ResizablePrimitive from 'react-resizable-panels';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
function ResizablePanelGroup({ className, ...props }: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
className={cn('flex h-full w-full aria-[orientation=vertical]:flex-col', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
@@ -30,14 +24,14 @@ function ResizableHandle({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className
|
||||
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -47,7 +41,7 @@ function ResizableHandle({
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup }
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
import * as React from 'react';
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
function ScrollArea({ className, children, ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Root data-slot="scroll-area" className={cn('relative', className)} {...props}>
|
||||
{/*
|
||||
Workaround for Radix ScrollArea bug #926:
|
||||
The Viewport's inner div uses display:table which breaks text-overflow:ellipsis.
|
||||
@@ -29,12 +21,12 @@ function ScrollArea({
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
@@ -42,12 +34,10 @@ function ScrollBar({
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' && 'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' && 'h-2.5 flex-col border-t border-t-transparent',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -56,8 +46,7 @@ function ScrollBar({
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
|
||||
@@ -1,36 +1,30 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
size?: 'sm' | 'default';
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
@@ -38,7 +32,7 @@ function SelectTrigger({
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -47,14 +41,14 @@ function SelectTrigger({
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
align = "center",
|
||||
position = 'popper',
|
||||
align = 'center',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
@@ -62,10 +56,10 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
@@ -74,9 +68,9 @@ function SelectContent({
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
@@ -84,33 +78,26 @@ function SelectContent({
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -121,38 +108,29 @@ function SelectItem({
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
@@ -162,15 +140,12 @@ function SelectScrollDownButton({
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -184,5 +159,4 @@ export {
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
import * as React from 'react';
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
|
||||
import { cn } from "../../src/lib/utils"
|
||||
import { cn } from '../../src/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
@@ -15,13 +15,12 @@ function Separator({
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
|
||||
export { Separator };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user