From 6fef6dae9fb5409919bcbced28470bcae9cbf344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Mon, 9 Feb 2026 16:43:54 +0100 Subject: [PATCH 1/2] feat: auto-end conversation sessions after inactivity (#40) Introduce a configurable session timeout so idle chat conversations are released automatically. This prevents conversations from staying locked when the user leaves the tab or stops interacting. Session timeout and countdown - Add CHAT_SESSION_TIMEOUT_MINUTES (default 5). Same value is used for server-side stale release and for the in-app countdown. - Show an "Ends in M:SS" countdown next to "Back to projects". It only resets when the user clicks the refresh (Continue) button or returns to the tab; automatic heartbeats do not reset it. - When the countdown reaches zero, the client POSTs to the conversation finish endpoint and redirects to the project list. Heartbeat and visibility - Heartbeats are sent only when the tab is visible. If the user switches to the project list or another tab, heartbeats stop and the session can be released after the timeout. - Stale release on the server only considers conversations that have no EditSession with status Running (no extra DB column). Countdown behaviour - While the agent is working: countdown is reset to full and frozen; it resumes when the agent is done and the user can enter new orders. - When the user types in the instruction field: countdown is reset to full so the session does not end while they are composing a message. Implementation - Chat controller dispatches custom events (agent-work-started, agent-work-finished, user-typed); conversation heartbeat controller listens and adjusts countdown and freeze state accordingly. Closes #40 --- .env | 2 + .../packages/chat_based_content_editor.yaml | 4 + config/services.yaml | 4 + .../Facade/ChatBasedContentEditorFacade.php | 14 +- .../ChatBasedContentEditorFacadeInterface.php | 4 +- .../Handler/RunEditSessionHandler.php | 12 +- .../ChatBasedContentEditorController.php | 2 + .../chat_based_content_editor_controller.ts | 18 ++ .../conversation_heartbeat_controller.ts | 162 +++++++++++++++++- .../templates/chat_based_content_editor.twig | 39 +++-- .../ChatBasedContentEditorFacadeTest.php | 45 +++++ .../ChatBasedContentEditorFacadeTest.php | 10 +- translations/messages.de.yaml | 2 + translations/messages.en.yaml | 2 + 14 files changed, 292 insertions(+), 28 deletions(-) diff --git a/.env b/.env index adefa71..bafdc94 100644 --- a/.env +++ b/.env @@ -61,6 +61,8 @@ ROLLOUT_SIGNAL_SECRET=secret LOCK_DSN="${DATABASE_URL}" ###< symfony/lock ### +CHAT_SESSION_TIMEOUT_MINUTES=5 + LLM_CONTENT_EDITOR_OPENAI_API_KEY=your-key-here ###> sitebuilder/llm-wire-log ### diff --git a/config/packages/chat_based_content_editor.yaml b/config/packages/chat_based_content_editor.yaml index 0b52fe6..4adf315 100644 --- a/config/packages/chat_based_content_editor.yaml +++ b/config/packages/chat_based_content_editor.yaml @@ -1,4 +1,8 @@ parameters: chat_based_content_editor.bytes_per_token_estimate: 4 + # Minutes of inactivity after which a conversation is auto-ended (only when conversation tab is not visible or no heartbeats). + # Set CHAT_SESSION_TIMEOUT_MINUTES in .env or .env.local to override. + chat_based_content_editor.session_timeout_minutes_default: 5 + chat_based_content_editor.session_timeout_minutes: "%env(default:chat_based_content_editor.session_timeout_minutes_default:int:CHAT_SESSION_TIMEOUT_MINUTES)%" # Estimated bytes for system prompt (included in "current context" for the budget bar) chat_based_content_editor.system_prompt_bytes_estimate: 8000 diff --git a/config/services.yaml b/config/services.yaml index a00a5bf..6a0122c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -25,6 +25,8 @@ services: _defaults: autowire: true # Automatically injects dependencies in your services. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + bind: + $sessionTimeoutMinutes: "%chat_based_content_editor.session_timeout_minutes%" # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name @@ -132,6 +134,8 @@ services: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface: class: App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacade + arguments: + $sessionTimeoutMinutes: "%chat_based_content_editor.session_timeout_minutes%" # Domain service bindings App\WorkspaceMgmt\Domain\Service\WorkspaceStatusGuardInterface: diff --git a/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacade.php b/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacade.php index 3c7736e..0093068 100644 --- a/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacade.php +++ b/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacade.php @@ -21,6 +21,7 @@ final class ChatBasedContentEditorFacade implements ChatBasedContentEditorFacade public function __construct( private readonly EntityManagerInterface $entityManager, private readonly WorkspaceMgmtFacadeInterface $workspaceMgmtFacade, + private readonly int $sessionTimeoutMinutes, ) { } @@ -63,23 +64,28 @@ public function getOngoingConversationUserId(string $workspaceId): ?string return $conversation?->getUserId(); } - public function releaseStaleConversations(int $timeoutMinutes = 5): array + public function releaseStaleConversations(?int $timeoutMinutes = null): array { - $cutoffTime = DateAndTimeService::getDateTimeImmutable()->modify("-{$timeoutMinutes} minutes"); + $minutes = $timeoutMinutes ?? $this->sessionTimeoutMinutes; + $cutoffTime = DateAndTimeService::getDateTimeImmutable()->modify("-{$minutes} minutes"); // Find ongoing conversations where: - // - lastActivityAt is set and is older than cutoff time, OR - // - lastActivityAt is null and createdAt is older than cutoff time (legacy/new conversations) + // - no edit session is currently running (agent not working), + // - and (lastActivityAt is set and older than cutoff, OR lastActivityAt is null and createdAt older than cutoff) /** @var list $staleConversations */ $staleConversations = $this->entityManager->createQueryBuilder() ->select('c') ->from(Conversation::class, 'c') ->where('c.status = :status') + ->andWhere( + 'NOT EXISTS (SELECT 1 FROM ' . EditSession::class . ' e WHERE e.conversation = c AND e.status = :runningStatus)' + ) ->andWhere( '(c.lastActivityAt IS NOT NULL AND c.lastActivityAt < :cutoffTime) OR ' . '(c.lastActivityAt IS NULL AND c.createdAt < :cutoffTime)' ) ->setParameter('status', ConversationStatus::ONGOING) + ->setParameter('runningStatus', EditSessionStatus::Running) ->setParameter('cutoffTime', $cutoffTime) ->getQuery() ->getResult(); diff --git a/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacadeInterface.php b/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacadeInterface.php index 334d1ce..ae0e10a 100644 --- a/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacadeInterface.php +++ b/src/ChatBasedContentEditor/Facade/ChatBasedContentEditorFacadeInterface.php @@ -29,11 +29,11 @@ public function getOngoingConversationUserId(string $workspaceId): ?string; * A conversation is considered stale if the user hasn't sent a heartbeat * (updated lastActivityAt) within the specified timeout. * - * @param int $timeoutMinutes number of minutes after which a conversation is considered stale + * @param int|null $timeoutMinutes number of minutes after which a conversation is considered stale (default: configured session_timeout_minutes) * * @return list list of workspace IDs that were released */ - public function releaseStaleConversations(int $timeoutMinutes = 5): array; + public function releaseStaleConversations(?int $timeoutMinutes = null): array; /** * Get the ID of the latest conversation for a workspace. diff --git a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php index 2136f70..bf732ee 100644 --- a/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php +++ b/src/ChatBasedContentEditor/Infrastructure/Handler/RunEditSessionHandler.php @@ -69,10 +69,13 @@ public function __invoke(RunEditSessionMessage $message): void $session->setStatus(EditSessionStatus::Running); $this->entityManager->flush(); + $conversationId = null; + try { // Load previous messages from conversation $previousMessages = $this->loadPreviousMessages($session); $conversation = $session->getConversation(); + $conversationId = $conversation->getId(); // Set execution context for agent container execution $workspace = $this->workspaceMgmtFacade->getWorkspaceById($conversation->getWorkspaceId()); @@ -182,7 +185,14 @@ public function __invoke(RunEditSessionMessage $message): void $session->setStatus(EditSessionStatus::Failed); $this->entityManager->flush(); } finally { - // Always clear execution context + // Refresh last activity when agent finishes so 5-min inactivity countdown starts from now + if ($conversationId !== null) { + $conversationToUpdate = $this->entityManager->find(Conversation::class, $conversationId); + if ($conversationToUpdate !== null) { + $conversationToUpdate->updateLastActivity(); + $this->entityManager->flush(); + } + } $this->executionContext->clearContext(); } } diff --git a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php index 47a6438..73e5b6a 100644 --- a/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php +++ b/src/ChatBasedContentEditor/Presentation/Controller/ChatBasedContentEditorController.php @@ -60,6 +60,7 @@ public function __construct( private readonly TranslatorInterface $translator, private readonly PromptSuggestionsService $promptSuggestionsService, private readonly LlmContentEditorFacadeInterface $llmContentEditorFacade, + private readonly int $sessionTimeoutMinutes, ) { } @@ -357,6 +358,7 @@ public function show( ] : null, 'remoteAssetBrowserWindowSize' => RemoteContentAssetsFacadeInterface::BROWSER_WINDOW_SIZE, 'promptSuggestions' => $promptSuggestions, + 'sessionTimeoutMinutes' => $this->sessionTimeoutMinutes, ]); } diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts index 4c5dcd3..4f95ce5 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/chat_based_content_editor_controller.ts @@ -135,9 +135,23 @@ export default class extends Controller { if (activeSession && activeSession.id) { this.resumeActiveSession(activeSession); } + + // Reset session countdown when user types in the instruction field + if (this.hasInstructionTarget) { + this.instructionInputListener = () => { + document.dispatchEvent(new CustomEvent("chat-based-content-editor:user-typed")); + }; + this.instructionTarget.addEventListener("input", this.instructionInputListener); + } } + private instructionInputListener: (() => void) | null = null; + disconnect(): void { + if (this.instructionInputListener !== null && this.hasInstructionTarget) { + this.instructionTarget.removeEventListener("input", this.instructionInputListener); + this.instructionInputListener = null; + } this.stopPolling(); this.stopContextUsagePolling(); } @@ -350,6 +364,7 @@ export default class extends Controller { pollUrl: this.pollUrlTemplateValue.replace("__SESSION_ID__", sessionId), }; this.isPollingActive = true; + document.dispatchEvent(new CustomEvent("chat-based-content-editor:agent-work-started")); this.pollSession(); } @@ -421,6 +436,9 @@ export default class extends Controller { } private stopPolling(): void { + if (this.isPollingActive) { + document.dispatchEvent(new CustomEvent("chat-based-content-editor:agent-work-finished")); + } this.isPollingActive = false; if (this.pollingTimeoutId !== null) { clearTimeout(this.pollingTimeoutId); diff --git a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/conversation_heartbeat_controller.ts b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/conversation_heartbeat_controller.ts index 82c9b3e..2af395f 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/conversation_heartbeat_controller.ts +++ b/src/ChatBasedContentEditor/Presentation/Resources/assets/controllers/conversation_heartbeat_controller.ts @@ -4,29 +4,116 @@ import { Controller } from "@hotwired/stimulus"; * Stimulus controller for sending periodic heartbeats to track user presence. * Prevents stale conversation locks by updating the lastActivityAt timestamp. * + * - Heartbeats are sent only when the tab is visible, so switching to the project + * page (or another tab) stops updates and the session can auto-end after timeout. + * - Countdown runs to 0 and only resets when the user clicks "Continue" or returns to the tab. + * Automatic heartbeats do NOT reset the countdown, so the session ends when countdown hits 0. + * - While the agent is working, the countdown is reset to full and frozen; it resumes when the agent is done. + * - Typing in the instruction field resets the countdown. + * * Uses non-overlapping polling: next heartbeat is scheduled only after the * current one completes, preventing request pile-up on slow connections. */ export default class extends Controller { static values = { heartbeatUrl: String, - interval: { type: Number, default: 10000 }, // 10 seconds default + interval: { type: Number, default: 10000 }, // 10 seconds + sessionTimeoutMinutes: { type: Number, default: 5 }, + translations: Object, + finishUrl: String, + finishCsrfToken: String, + projectListUrl: String, }; + static targets = ["countdown"]; + declare readonly heartbeatUrlValue: string; declare readonly intervalValue: number; + declare readonly sessionTimeoutMinutesValue: number; + declare readonly translationsValue: { endsIn: string; continue: string }; + declare readonly finishUrlValue: string; + declare readonly finishCsrfTokenValue: string; + declare readonly projectListUrlValue: string; + declare readonly countdownTarget: HTMLElement | undefined; private heartbeatTimeoutId: ReturnType | null = null; + private countdownTickId: ReturnType | null = null; + private countdownSeconds: number = 0; private isActive: boolean = false; + private sessionEnded: boolean = false; + private visibilityListener: (() => void) | null = null; + private agentWorkStartedListener: (() => void) | null = null; + private agentWorkFinishedListener: (() => void) | null = null; + private userTypedListener: (() => void) | null = null; connect(): void { this.isActive = true; - this.sendHeartbeat(); + this.countdownSeconds = this.sessionTimeoutMinutesValue * 60; + this.updateCountdownDisplay(); + this.startCountdownTick(); + this.visibilityListener = () => this.handleVisibilityChange(); + document.addEventListener("visibilitychange", this.visibilityListener); + + this.agentWorkStartedListener = () => this.onAgentWorkStarted(); + this.agentWorkFinishedListener = () => this.onAgentWorkFinished(); + document.addEventListener("chat-based-content-editor:agent-work-started", this.agentWorkStartedListener); + document.addEventListener("chat-based-content-editor:agent-work-finished", this.agentWorkFinishedListener); + + this.userTypedListener = () => this.resetCountdown(); + document.addEventListener("chat-based-content-editor:user-typed", this.userTypedListener); + + if (document.visibilityState === "visible") { + this.sendHeartbeat(); + } } disconnect(): void { this.isActive = false; this.stopHeartbeat(); + this.stopCountdownTick(); + if (this.visibilityListener !== null) { + document.removeEventListener("visibilitychange", this.visibilityListener); + } + if (this.agentWorkStartedListener !== null) { + document.removeEventListener("chat-based-content-editor:agent-work-started", this.agentWorkStartedListener); + } + if (this.agentWorkFinishedListener !== null) { + document.removeEventListener( + "chat-based-content-editor:agent-work-finished", + this.agentWorkFinishedListener, + ); + } + if (this.userTypedListener !== null) { + document.removeEventListener("chat-based-content-editor:user-typed", this.userTypedListener); + } + } + + /** + * Called when the user clicks "Continue" to extend the session (sends heartbeat and resets countdown). + */ + continueClicked(): void { + if (document.visibilityState === "visible") { + this.resetCountdown(); + this.sendHeartbeat(); + } + } + + private handleVisibilityChange(): void { + if (document.visibilityState === "visible") { + this.resetCountdown(); + this.sendHeartbeat(); + } else { + this.stopHeartbeat(); + } + } + + private onAgentWorkStarted(): void { + this.resetCountdown(); + this.stopCountdownTick(); + } + + private onAgentWorkFinished(): void { + this.startCountdownTick(); } private stopHeartbeat(): void { @@ -37,12 +124,77 @@ export default class extends Controller { } private scheduleNextHeartbeat(): void { - if (this.isActive) { + if (this.isActive && !this.sessionEnded && document.visibilityState === "visible") { this.heartbeatTimeoutId = setTimeout(() => this.sendHeartbeat(), this.intervalValue); } } + private resetCountdown(): void { + this.countdownSeconds = this.sessionTimeoutMinutesValue * 60; + this.updateCountdownDisplay(); + } + + private startCountdownTick(): void { + this.stopCountdownTick(); + this.countdownTickId = setInterval(() => { + if (this.countdownSeconds > 0) { + this.countdownSeconds -= 1; + this.updateCountdownDisplay(); + } else if (!this.sessionEnded) { + this.endSessionDueToTimeout(); + } + }, 1000); + } + + private stopCountdownTick(): void { + if (this.countdownTickId !== null) { + clearInterval(this.countdownTickId); + this.countdownTickId = null; + } + } + + private updateCountdownDisplay(): void { + if (this.countdownTarget == null) { + return; + } + const m = Math.floor(this.countdownSeconds / 60); + const s = this.countdownSeconds % 60; + const text = `${this.translationsValue.endsIn} ${m}:${s.toString().padStart(2, "0")}`; + this.countdownTarget.textContent = text; + } + + /** + * When countdown reaches 0: finish the session on the server and redirect to project list. + */ + private async endSessionDueToTimeout(): Promise { + if (this.sessionEnded) { + return; + } + this.sessionEnded = true; + this.stopHeartbeat(); + this.stopCountdownTick(); + this.isActive = false; + + try { + const formData = new FormData(); + formData.append("_csrf_token", this.finishCsrfTokenValue); + await fetch(this.finishUrlValue, { + method: "POST", + body: formData, + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + }); + } catch { + // Continue to redirect even if request failed + } + window.location.href = this.projectListUrlValue; + } + private async sendHeartbeat(): Promise { + if (this.sessionEnded) { + return; + } try { const response = await fetch(this.heartbeatUrlValue, { method: "POST", @@ -52,14 +204,12 @@ export default class extends Controller { }); if (!response.ok) { - // If the conversation is no longer accessible, stop heartbeating - // This could happen if the conversation was finished or the user was logged out if (response.status === 403 || response.status === 404 || response.status === 400) { this.isActive = false; - return; } } + // Do NOT reset countdown on automatic heartbeat – only "Continue" and tab focus reset it } catch { // Network error - keep trying, might be temporary } diff --git a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig index 4872335..942e417 100644 --- a/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig +++ b/src/ChatBasedContentEditor/Presentation/Resources/templates/chat_based_content_editor.twig @@ -3,14 +3,6 @@ {% block title %}{{ 'editor.title'|trans }}{% endblock %} {% block content %} - {# Heartbeat controller to track user presence (prevents stale conversation locks) - skip for read-only #} - {% if not readOnly %} -
- {% endif %} -
{% endif %} - {# Action buttons - always visible, centered above header #} -
+ {# Action buttons - always visible, centered above header. Heartbeat + countdown only when editable. #} +
{% if canEdit %}
@@ -130,6 +136,19 @@ class="px-3 py-1.5 rounded-md border border-dark-300 dark:border-dark-600 text-dark-700 dark:text-dark-300 text-sm font-medium hover:bg-dark-100 dark:hover:bg-dark-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-dark-900"> {{ 'common.back_to_projects'|trans }} + {% if canEdit %} + + + {% endif %}
{# Main content area - side-by-side on wide screens #} diff --git a/tests/Integration/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php b/tests/Integration/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php index b4796fc..864efe1 100644 --- a/tests/Integration/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php +++ b/tests/Integration/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php @@ -6,7 +6,9 @@ use App\Account\Domain\Entity\AccountCore; use App\ChatBasedContentEditor\Domain\Entity\Conversation; +use App\ChatBasedContentEditor\Domain\Entity\EditSession; use App\ChatBasedContentEditor\Domain\Enum\ConversationStatus; +use App\ChatBasedContentEditor\Domain\Enum\EditSessionStatus; use App\ChatBasedContentEditor\Facade\ChatBasedContentEditorFacadeInterface; use App\ProjectMgmt\Domain\Entity\Project; use App\WorkspaceMgmt\Domain\Entity\Workspace; @@ -195,6 +197,49 @@ public function testReleaseStaleConversationsReleasesConversationWithNullLastAct self::assertSame(ConversationStatus::FINISHED, $updatedConversation->getStatus()); } + public function testReleaseStaleConversationsDoesNotReleaseWhenAgentIsWorking(): void + { + // Arrange: Conversation that would be stale (lastActivityAt 6 min ago) but has a Running edit session + $user = $this->createTestUser('user@example.com'); + $project = $this->createProject('Test Project'); + $projectId = $project->getId(); + self::assertNotNull($projectId); + + $workspace = $this->createWorkspace($projectId, WorkspaceStatus::IN_CONVERSATION); + $workspaceId = $workspace->getId(); + $userId = $user->getId(); + self::assertNotNull($workspaceId); + self::assertNotNull($userId); + + $conversation = $this->createConversation($workspaceId, $userId); + $conversationId = $conversation->getId(); + self::assertNotNull($conversationId); + + $conversation->updateLastActivity(); + $runningSession = new EditSession($conversation, 'test instruction'); + $runningSession->setStatus(EditSessionStatus::Running); + $this->entityManager->persist($runningSession); + $this->entityManager->flush(); + + // Time travel to 10:06 (past the 5-minute timeout) + $this->mockClock->modify('+6 minutes'); + + // Act: Release stale conversations + $releasedWorkspaceIds = $this->facade->releaseStaleConversations(5); + + // Assert: Conversation must NOT be released because it has a Running edit session + self::assertCount(0, $releasedWorkspaceIds); + + $this->entityManager->clear(); + $updatedConversation = $this->entityManager->find(Conversation::class, $conversationId); + self::assertNotNull($updatedConversation); + self::assertSame(ConversationStatus::ONGOING, $updatedConversation->getStatus()); + + $updatedWorkspace = $this->entityManager->find(Workspace::class, $workspaceId); + self::assertNotNull($updatedWorkspace); + self::assertSame(WorkspaceStatus::IN_CONVERSATION, $updatedWorkspace->getStatus()); + } + private function createTestUser(string $email): AccountCore { $user = new AccountCore($email, 'hashed-password'); diff --git a/tests/Unit/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php b/tests/Unit/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php index 93a60e8..e7db406 100644 --- a/tests/Unit/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php +++ b/tests/Unit/ChatBasedContentEditor/ChatBasedContentEditorFacadeTest.php @@ -67,7 +67,7 @@ public function testReleaseStaleConversationsFinishesStaleConversationsAndTransi }); // Act - $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade); + $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade, 5); $result = $facade->releaseStaleConversations(5); // Assert @@ -103,7 +103,7 @@ public function testReleaseStaleConversationsReturnsEmptyArrayWhenNoStaleConvers $workspaceFacade->expects($this->never())->method('transitionToAvailableForConversation'); // Act - $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade); + $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade, 5); $result = $facade->releaseStaleConversations(5); // Assert @@ -153,7 +153,7 @@ public function testReleaseStaleConversationsDeduplicatesWorkspaceIds(): void ->with('workspace-1'); // Act - $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade); + $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade, 5); $result = $facade->releaseStaleConversations(5); // Assert @@ -193,7 +193,7 @@ public function testGetLatestConversationIdReturnsIdWhenConversationExists(): vo $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); // Act - $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade); + $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade, 5); $result = $facade->getLatestConversationId('workspace-1'); // Assert @@ -224,7 +224,7 @@ public function testGetLatestConversationIdReturnsNullWhenNoConversationExists() $workspaceFacade = $this->createMock(WorkspaceMgmtFacadeInterface::class); // Act - $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade); + $facade = new ChatBasedContentEditorFacade($entityManager, $workspaceFacade, 5); $result = $facade->getLatestConversationId('workspace-1'); // Assert diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index 0068e40..768b78e 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -184,6 +184,8 @@ editor: working_on: "Arbeitet an %project%" description: "Beschreiben Sie, was Sie ändern möchten, in einfacher Sprache. Der Assistent kümmert sich um die Änderungen und zeigt den Fortschritt an." end_session: "Sitzung beenden" + session_continue: "Weiter" + session_ends_in: "Sitzung endet in" send_for_review: "Zur Überprüfung senden" conversation_placeholder: "Ihre Unterhaltung wird hier angezeigt. Beschreiben Sie unten eine Änderung, um zu beginnen." auto_scroll: "Neueste Nachricht im Blick behalten" diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 17447c7..ff526c9 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -184,6 +184,8 @@ editor: working_on: "Working on %project%" description: "Describe what you want changed in plain language. The assistant will take care of the edits and show progress." end_session: "End session" + session_continue: "Continue" + session_ends_in: "Session ends in" send_for_review: "Send for review" conversation_placeholder: "Your conversation will show up here. Describe a change below to get started." auto_scroll: "Keep newest message in view" From cb1962b9697b2051b6af4fe63672ebe5cdfc2dd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20K=C3=B6lbl?= Date: Thu, 12 Feb 2026 16:52:26 +0100 Subject: [PATCH 2/2] fix: add missing Gemini25FlashLite and Gemini25FlashImage to LlmModelName match expressions Post-merge fix: the new enum cases introduced on main were not handled in maxContextTokens(), inputCostPer1M(), outputCostPer1M(), and isImageGenerationModel(), causing PHPStan errors. Co-authored-by: Cursor --- src/LlmContentEditor/Domain/Enum/LlmModelName.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/LlmContentEditor/Domain/Enum/LlmModelName.php b/src/LlmContentEditor/Domain/Enum/LlmModelName.php index d51aa0d..0fa0f23 100644 --- a/src/LlmContentEditor/Domain/Enum/LlmModelName.php +++ b/src/LlmContentEditor/Domain/Enum/LlmModelName.php @@ -21,8 +21,8 @@ public function maxContextTokens(): int { return match ($this) { self::Gpt52 => 128_000, - self::Gemini3ProPreview, self::Gemini3FlashPreview => 1_048_576, - self::GptImage1, self::Gemini3ProImagePreview => 0, + self::Gemini25FlashLite, self::Gemini3ProPreview, self::Gemini3FlashPreview => 1_048_576, + self::GptImage1, self::Gemini25FlashImage, self::Gemini3ProImagePreview => 0, }; } @@ -34,8 +34,10 @@ public function inputCostPer1M(): float return match ($this) { self::Gpt52 => 1.75, self::Gemini3ProPreview => 1.25, + self::Gemini25FlashLite => 0.075, self::Gemini3FlashPreview => 0.15, self::GptImage1 => 0.0, // image models have per-image pricing + self::Gemini25FlashImage => 0.0, self::Gemini3ProImagePreview => 0.0, }; } @@ -48,8 +50,10 @@ public function outputCostPer1M(): float return match ($this) { self::Gpt52 => 14.00, self::Gemini3ProPreview => 10.00, + self::Gemini25FlashLite => 0.30, self::Gemini3FlashPreview => 0.60, self::GptImage1 => 0.0, + self::Gemini25FlashImage => 0.0, self::Gemini3ProImagePreview => 0.0, }; } @@ -65,7 +69,7 @@ public static function defaultForContentEditor(): self public function isImageGenerationModel(): bool { return match ($this) { - self::GptImage1, self::Gemini3ProImagePreview => true, + self::GptImage1, self::Gemini25FlashImage, self::Gemini3ProImagePreview => true, default => false, }; }