Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/dreams-dark-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/dreams-dark-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/dreams-light-detail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/dreams-light-list.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
148 changes: 148 additions & 0 deletions packages/web/e2e/dreams.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { expect, test } from "@playwright/test";

const STORE_KEY = "openconcho:instances";
const STORE_VALUE = JSON.stringify({
instances: [{ id: "i1", name: "Local", baseUrl: "http://localhost:9999", token: "" }],
activeId: "i1",
});

test.describe("Dreams route", () => {
test.beforeEach(async ({ context }) => {
await context.addInitScript(
([key, value]) => {
window.localStorage.setItem(key, value);
},
[STORE_KEY, STORE_VALUE],
);
// Stub the conclusions/list endpoint so the route can render real dreams.
// :9999 is unreachable; this intercept replaces the network call entirely.
// Use a function matcher so the trailing query string (?page=&page_size=) doesn't
// break a glob.
await context.route(
(url) => url.pathname.endsWith("/conclusions/list"),
async (route) => {
const now = Date.now();
const iso = (offsetMs: number) => new Date(now - offsetMs).toISOString();
const items = [
// Dream A — burst
{
id: "ind-1",
content: "Alice prefers asynchronous communication",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(1000),
conclusion_type: "inductive",
reasoning_tree: {
conclusion_id: "ind-1",
premises: [{ conclusion_id: "ded-1" }],
},
},
{
id: "ded-1",
content: "Alice mentioned email twice and declined two meetings",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(2000),
conclusion_type: "deductive",
reasoning_tree: {
conclusion_id: "ded-1",
premises: [{ conclusion_id: "exp-1" }, { conclusion_id: "exp-2" }],
},
},
{
id: "exp-1",
content: "Alice said 'just email me'",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(3000),
conclusion_type: "explicit",
},
{
id: "exp-2",
content: "Alice declined the Tuesday standup",
observer_id: "alice",
observed_id: "bob",
session_id: "sess-1",
created_at: iso(4000),
conclusion_type: "explicit",
},
// Dream B — 30 minutes ago, different pair → clusters separately
{
id: "ded-2",
content: "Carol responds in the evenings",
observer_id: "carol",
observed_id: "dan",
session_id: "sess-2",
created_at: iso(30 * 60_000),
conclusion_type: "deductive",
},
];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items,
total: items.length,
pages: 1,
page: 1,
size: items.length,
}),
});
},
);
});

test("shows a Dreams entry in the workspace sub-nav", async ({ page }) => {
await page.goto("/workspaces/ws-test/dreams");
// Sidebar link with the Dreams label
const dreamsLink = page.getByRole("link", { name: /^Dreams$/ });
await expect(dreamsLink.first()).toBeVisible();
});

test("renders heading and breadcrumb on the dreams route", async ({ page }) => {
await page.goto("/workspaces/ws-test/dreams");
await expect(page.getByRole("heading", { name: /^Dreams$/ })).toBeVisible();
// Breadcrumb specifically — the sidebar has a "Workspaces" link too, so scope.
await expect(
page.getByLabel("Breadcrumb").getByRole("link", { name: "Workspaces" }),
).toBeVisible();
});

test("clusters mocked conclusions into dreams and opens detail on click", async ({ page }) => {
await page.goto("/workspaces/ws-test/dreams");

// Two dreams: alice→bob burst, and the older carol→dan
const rows = page.locator('button[aria-pressed]');
await expect(rows).toHaveCount(2);

// Alice→bob row should show count chips
await expect(rows.first()).toContainText("alice");
await expect(rows.first()).toContainText("bob");
await expect(rows.first()).toContainText("2 explicit");
await expect(rows.first()).toContainText("1 deductive");
await expect(rows.first()).toContainText("1 inductive");

// Click → detail panel renders three columns
await rows.first().click();
await expect(page.getByText("Dream detail")).toBeVisible();
await expect(page.getByText("Explicit", { exact: true })).toBeVisible();
await expect(page.getByText("Deductive", { exact: true })).toBeVisible();
await expect(page.getByText("Inductive", { exact: true })).toBeVisible();
});

test("expands premise tree for an inductive conclusion", async ({ page }) => {
await page.goto("/workspaces/ws-test/dreams");
await page.locator('button[aria-pressed]').first().click();

const showPremises = page.getByRole("button", { name: /^Show premises$/i });
await expect(showPremises).toBeVisible();
await showPremises.click();

// The reasoning chain renders with the deductive premise (ded-1)
await expect(page.getByText("Reasoning chain")).toBeVisible();
await expect(page.getByLabel("Premise tree")).toBeVisible();
});
});
3 changes: 3 additions & 0 deletions packages/web/src/api/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,8 @@ export const QK = {
conclusionsQuery: (wsId: string, q: string, filters: Record<string, unknown>) =>
["conclusions-query", wsId, q, filters] as const,

dreams: (wsId: string, filters: Record<string, unknown>, limit: number) =>
["dreams", wsId, filters, limit] as const,

webhooks: (wsId: string) => ["webhooks", wsId] as const,
};
44 changes: 44 additions & 0 deletions packages/web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,50 @@ export function useDeleteConclusion(workspaceId: string) {
});
}

// ─── Dreams ───────────────────────────────────────────────────────────────────
//
// Dreams are synthetic groupings of conclusions: bursts produced by a single
// dream run for one (observer, observed) pair. We fetch a generous batch of
// conclusions and let the UI cluster them via `clusterConclusionsIntoDreams`.

const DREAM_FETCH_PAGE_SIZE = 100;
const DREAM_MAX_PAGES = 4;

export function useDreams(
workspaceId: string,
filters: Record<string, unknown> = {},
limit = DREAM_FETCH_PAGE_SIZE * DREAM_MAX_PAGES,
) {
return useQuery({
queryKey: QK.dreams(workspaceId, filters, limit),
queryFn: async () => {
const collected: unknown[] = [];
const pageSize = Math.min(DREAM_FETCH_PAGE_SIZE, limit);
let page = 1;
while (collected.length < limit) {
const { data, error } = await client.current.POST(
"/v3/workspaces/{workspace_id}/conclusions/list",
{
params: {
path: { workspace_id: workspaceId },
query: { page, page_size: pageSize, reverse: false },
},
body: filters,
},
);
if (error) err(error);
const items = (data as { items?: unknown[] } | undefined)?.items ?? [];
const totalPages = (data as { pages?: number } | undefined)?.pages ?? 1;
collected.push(...items);
if (items.length === 0 || page >= totalPages || page >= DREAM_MAX_PAGES) break;
page++;
}
return collected.slice(0, limit);
},
enabled: Boolean(workspaceId),
});
}

// ─── Webhooks ─────────────────────────────────────────────────────────────────

export function useWebhooks(workspaceId: string) {
Expand Down
Loading
Loading