diff --git a/apps/api/src/main/kotlin/com/plot/api/routine/AgentRunQueryPersistence.kt b/apps/api/src/main/kotlin/com/plot/api/routine/AgentRunQueryPersistence.kt index 9b7d82b..41f6066 100644 --- a/apps/api/src/main/kotlin/com/plot/api/routine/AgentRunQueryPersistence.kt +++ b/apps/api/src/main/kotlin/com/plot/api/routine/AgentRunQueryPersistence.kt @@ -15,17 +15,17 @@ class AgentRunQueryPersistence( workspaceId, id, ).firstOrNull() - fun chatExists(workspaceId: UUID, chatId: UUID): Boolean = sqlExecutor.queryForObject( + fun sessionExists(workspaceId: UUID, sessionId: UUID): Boolean = sqlExecutor.queryForObject( "select exists(select 1 from work_sessions where workspace_id = ? and id = ?)", Boolean::class.java, workspaceId, - chatId, + sessionId, ) ?: false - fun listChatAgentRuns(workspaceId: UUID, chatId: UUID): List = sqlExecutor.query( - selectAgentRunSql + " where a.workspace_id = ? and a.work_session_id = ? and a.origin = 'CHAT' order by a.created_at, a.id", + fun listSessionAgentRuns(workspaceId: UUID, sessionId: UUID): List = sqlExecutor.query( + selectAgentRunSql + " where a.workspace_id = ? and a.work_session_id = ? order by a.created_at, a.id", agentRunMapper, workspaceId, - chatId, + sessionId, ) fun findChatAgentRunByIdempotencyKey( workspaceId: UUID, diff --git a/apps/api/src/main/kotlin/com/plot/api/routine/ChatAgentAdmissionService.kt b/apps/api/src/main/kotlin/com/plot/api/routine/ChatAgentAdmissionService.kt index a054082..63abead 100644 --- a/apps/api/src/main/kotlin/com/plot/api/routine/ChatAgentAdmissionService.kt +++ b/apps/api/src/main/kotlin/com/plot/api/routine/ChatAgentAdmissionService.kt @@ -185,11 +185,11 @@ class ChatAgentAdmissionService( return run.toChatResponseFor(agentRunQueryPersistence) } - fun listForChat(chatId: UUID): List { - if (!agentRunQueryPersistence.chatExists(devContext.devWorkspaceId, chatId)) { + fun listForSession(sessionId: UUID): List { + if (!agentRunQueryPersistence.sessionExists(devContext.devWorkspaceId, sessionId)) { throw ApiException(HttpStatus.NOT_FOUND, "NOT_FOUND", "Chat not found") } - return agentRunQueryPersistence.listChatAgentRuns(devContext.devWorkspaceId, chatId) + return agentRunQueryPersistence.listSessionAgentRuns(devContext.devWorkspaceId, sessionId) .map { it.toChatResponseFor(agentRunQueryPersistence) } } diff --git a/apps/api/src/main/kotlin/com/plot/api/worksession/WorkSessionController.kt b/apps/api/src/main/kotlin/com/plot/api/worksession/WorkSessionController.kt index d995f3f..4d64cde 100644 --- a/apps/api/src/main/kotlin/com/plot/api/worksession/WorkSessionController.kt +++ b/apps/api/src/main/kotlin/com/plot/api/worksession/WorkSessionController.kt @@ -27,7 +27,7 @@ class WorkSessionController( } @GetMapping("/{id}/agent-runs") - fun listAgentRuns(@PathVariable id: UUID): List = chatAgentAdmissionService.listForChat(id) + fun listAgentRuns(@PathVariable id: UUID): List = chatAgentAdmissionService.listForSession(id) @PostMapping fun create(@RequestBody request: CreateWorkSessionRequest): WorkSessionResponse { diff --git a/apps/api/src/test/kotlin/com/plot/api/routine/AgentRunWorkerIntegrationTest.kt b/apps/api/src/test/kotlin/com/plot/api/routine/AgentRunWorkerIntegrationTest.kt index 9549760..f3bf408 100644 --- a/apps/api/src/test/kotlin/com/plot/api/routine/AgentRunWorkerIntegrationTest.kt +++ b/apps/api/src/test/kotlin/com/plot/api/routine/AgentRunWorkerIntegrationTest.kt @@ -223,6 +223,14 @@ class AgentRunWorkerIntegrationTest { routinePersistence.find(routine.workspaceId, routine.id)?.activityCursorSequence, ) assertEquals(1, count("select count(*) from content_packs where generation_run_id = ?", artifactWorkflowRunId)) + val routineChatId = jdbcTemplate.queryForObject( + "select work_session_id from agent_runs where id = ?", + UUID::class.java, + agentRunId, + )!! + val historyRuns = chatAdmission.listForSession(routineChatId) + assertEquals(listOf(agentRunId), historyRuns.map { it.id }) + assertNotNull(historyRuns.single().artifactId) assertEquals(3, agentModel.requests.size) assertTrue(agentModel.requests.none { request -> request.toString().contains("MUTATED SECRET") || request.toString().contains("Authorization") diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 5153fe8..8283a5b 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -1,3 +1,5 @@ +import "@astryxdesign/core/astryx.css"; + import type { ReactNode } from "react"; import { ProductShell } from "@/components/layout/product-shell"; diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a92c29b..05b0b0a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,4 +1,10 @@ -@import 'tailwindcss'; +@layer reset, theme, base, astryx-base, astryx-theme, components, utilities; + +@import 'tailwindcss/theme.css' layer(theme); +@import 'tailwindcss/preflight.css' layer(base); +@import '@astryxdesign/core/reset.css'; +@import '@astryxdesign/core/tailwind-theme.css'; +@import 'tailwindcss/utilities.css' layer(utilities); @custom-variant dark (&:is(.dark *)); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 1869213..57304f4 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -60,6 +60,7 @@ export default function RootLayout({ return ( {children} diff --git a/apps/web/src/components/layout/product-shell.test.tsx b/apps/web/src/components/layout/product-shell.test.tsx index ba460d9..a03978c 100644 --- a/apps/web/src/components/layout/product-shell.test.tsx +++ b/apps/web/src/components/layout/product-shell.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; const navigation = vi.hoisted(() => ({ pathname: "/artifacts" })); @@ -10,19 +10,20 @@ vi.mock("next/navigation", () => ({ })); vi.mock("@/components/layout/product-sidebar", () => ({ - ProductSidebar: () => null, + ProductSidebar: ({ onThemeChange }: { onThemeChange: (theme: "system" | "light" | "dark") => void }) => ( +
+ + +
+ ), })); import { ProductShell } from "./product-shell"; -describe("ProductShell mobile navigation", () => { +describe("ProductShell", () => { beforeEach(() => { navigation.pathname = "/artifacts"; - vi.stubGlobal("matchMedia", () => ({ - matches: false, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - })); + document.documentElement.dataset.theme = "light"; }); it("links directly to workspace Settings and marks Artifacts as current", () => { @@ -53,4 +54,23 @@ describe("ProductShell mobile navigation", () => { expect(screen.getByRole("link", { name: "Chat" })).not.toHaveAttribute("aria-current"); expect(screen.getByRole("link", { name: "Artifacts" })).not.toHaveAttribute("aria-current"); }); + + it("keeps the document theme in sync with the product theme", async () => { + const { unmount } = render( + +
Content
+
, + ); + + fireEvent.click(screen.getByRole("button", { name: "Use dark theme" })); + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("dark")); + + fireEvent.click(screen.getByRole("button", { name: "Use light theme" })); + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("light")); + + fireEvent.click(screen.getByRole("button", { name: "Use dark theme" })); + await waitFor(() => expect(document.documentElement.dataset.theme).toBe("dark")); + unmount(); + expect(document.documentElement.dataset.theme).toBe("light"); + }); }); diff --git a/apps/web/src/components/layout/product-shell.tsx b/apps/web/src/components/layout/product-shell.tsx index 7bca425..6854477 100644 --- a/apps/web/src/components/layout/product-shell.tsx +++ b/apps/web/src/components/layout/product-shell.tsx @@ -38,6 +38,16 @@ export function ProductShell({ children }: { children: ReactNode }) { const darkMode = theme === "dark" || (theme === "system" && systemDark); + useEffect(() => { + document.documentElement.dataset.theme = darkMode ? "dark" : "light"; + }, [darkMode]); + + useEffect(() => { + return () => { + document.documentElement.dataset.theme = "light"; + }; + }, []); + return (
diff --git a/apps/web/src/features/artifacts/artifact-document-surface.tsx b/apps/web/src/features/artifacts/artifact-document-surface.tsx index 17f8263..56137b8 100644 --- a/apps/web/src/features/artifacts/artifact-document-surface.tsx +++ b/apps/web/src/features/artifacts/artifact-document-surface.tsx @@ -15,7 +15,7 @@ type ArtifactDocumentSurfaceProps = { onDraftChange?: (draft: Omit) => void; onSaveArtifact: (input: SaveArtifactInput) => Promise; onPackChange: (pack: Artifact) => void; - presentation?: "panel" | "canvas"; + presentation?: "panel" | "canvas" | "workspace"; saveRequestToken?: number; }; @@ -35,10 +35,18 @@ export function ArtifactDocumentSurface({ const readOnly = Boolean(historical); const shownPack = historical?.artifact ?? pack; - if (presentation === "canvas") { + if (presentation === "canvas" || presentation === "workspace") { + const workspacePresentation = presentation === "workspace"; return ( -
-

{shownPack.title || "Generated artifact"}

+
+ {!workspacePresentation ? ( +

{shownPack.title || "Generated artifact"}

+ ) : null} ("assistant"); + const [mobilePanel, setMobilePanel] = useState<"assistant" | "history" | null>(null); + const [artifactPanelOpen, setArtifactPanelOpen] = useState(false); + const [artifactHistoryOpen, setArtifactHistoryOpen] = useState(false); + const [artifactSaveRequestToken, setArtifactSaveRequestToken] = useState(0); + const artifactPanel = useResizable({ defaultSize: 720, minSizePx: 420, maxSizePx: 1200 }); + const resizeArtifactPanel = artifactPanel.resize; const mobileAssistantTriggerRef = useRef(null); const mobileHistoryTriggerRef = useRef(null); + const artifactTriggerRef = useRef(null); + const artifactHistoryTriggerRef = useRef(null); + const workspaceRef = useRef(null); + const artifactPanelInitializedRef = useRef(false); const previousMobilePanelRef = useRef<"assistant" | "history" | null>(null); const onAgentArtifact = useCallback((run: ChatAgentRun) => { @@ -48,6 +74,17 @@ export function ChatActiveWorkspace({ activeChat, references, sourceError, reque requestedArtifactId, selectedActivityArtifactId: agent.selectedActivity?.artifactId ?? null, }); + const shownArtifact = document.historicalArtifact?.artifact ?? document.currentArtifact; + const artifactMetrics = useMemo(() => { + if (!shownArtifact) return null; + const draft = document.historicalArtifact ? undefined : document.drafts[shownArtifact.id]; + const statements = draft?.statements ?? shownArtifact.variant.sentences; + const text = statements.map((statement) => statement.body).join("\n"); + return { + characters: text.length.toLocaleString("en-US"), + words: (text.trim() ? text.trim().split(/\s+/u).length : 0).toLocaleString("en-US"), + }; + }, [document.drafts, document.historicalArtifact, shownArtifact]); useEffect(() => { const previous = previousMobilePanelRef.current; @@ -57,6 +94,15 @@ export function ChatActiveWorkspace({ activeChat, references, sourceError, reque previousMobilePanelRef.current = mobilePanel; }, [mobilePanel]); + useEffect(() => { + if (!artifactPanelOpen || artifactPanelInitializedRef.current) return; + if (!window.matchMedia("(min-width: 1024px)").matches) return; + const workspaceWidth = workspaceRef.current?.getBoundingClientRect().width; + if (!workspaceWidth) return; + resizeArtifactPanel(Math.round(workspaceWidth / 2)); + artifactPanelInitializedRef.current = true; + }, [artifactPanelOpen, resizeArtifactPanel]); + const selectActivity = useCallback((activity: ChatAgentRun) => { document.resetHistory(); if (!activity.artifactId) setMobilePanel("assistant"); @@ -69,60 +115,70 @@ export function ChatActiveWorkspace({ activeChat, references, sourceError, reque .map((activity) => ({ id: activity.id, role: "user" as const, - timestamp: formatActivity(activity.createdAt), - createdAt: activity.createdAt, content: activity.instruction!, })); return current.length ? current - : [{ id: activeChat.id, role: "user" as const, timestamp: "Request", createdAt: null, content: activeChat.title || "Untitled request" }]; + : [{ id: activeChat.id, role: "user" as const, content: activeChat.title || "Untitled request" }]; }, [activeChat.id, activeChat.title, agent.activities]); return ( -
+
-
-
-