diff --git a/.env b/.env index 5203891..b19a478 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 238be52..74f266c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -29,6 +29,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 @@ -136,6 +138,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 96b236b..1ecce71 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 ff32943..abdfb85 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, ) { } @@ -358,6 +359,7 @@ public function show( ] : null, 'remoteAssetBrowserWindowSize' => RemoteContentAssetsFacadeInterface::BROWSER_WINDOW_SIZE, 'promptSuggestions' => $promptSuggestions, + 'sessionTimeoutMinutes' => $this->sessionTimeoutMinutes, 'prefillMessage' => $request->query->getString('prefill'), ]); } 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 8d78dc6..0ab94ae 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 @@ -138,6 +138,14 @@ export default class extends Controller { 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); + } + // Pre-fill instruction textarea if a prefill message was provided (e.g. from PhotoBuilder) if (this.prefillMessageValue && this.hasInstructionTarget) { this.instructionTarget.value = this.prefillMessageValue; @@ -145,7 +153,13 @@ export default class extends Controller { } } + 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(); } @@ -358,6 +372,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(); } @@ -429,6 +444,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 77d0a10..1a0d6c1 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, right-aligned above header #} -
+ {# Action buttons - always visible, centered above header. Heartbeat + countdown only when editable. #} +
{% if canEdit %}
@@ -128,6 +134,19 @@ {{ 'common.back_to_projects'|trans }} + {% if canEdit %} + + + {% endif %}
{# Main content area - side-by-side on wide screens #} 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, }; } 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 8923a9f..84c6416 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -192,6 +192,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 13a7f48..58f5cd5 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -192,6 +192,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"