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
2 changes: 2 additions & 0 deletions docs/IMPLEMENTATION_MASTERPLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Last Updated: 2026-09-10

Board-object proposal overlay continuation (#2808): complete the contextual preview path with a shared board-owned marker provider and one read-only checked preview panel. Cards and columns in both renderers consume presentation markers; the saved board model is unchanged. Original/pinned revision identity and short server-relative freshness are checked before display, and Review retains all decisions. General before/after board simulation and provider quality acceptance are not implied by this projection.

Private audio continuation (#2808): retain an original recording first, add manual written representations separately, then explicitly confirm into private memory. The vertical uses native source assets, bounded SQLite chunks, owner-scoped playback, idempotent upload retry, version conflicts and account portability/erasure. All four experiences share the question UI; automated transcription, real-device qualification and the wider source/attention work remain separate. This is a prototype delivery path with executable evidence, not a provider or release acceptance decision.

Original-source Companion continuation (#2808): explicit per-asset source selection builds on native private-memory preservation. Bounded owner-scoped source queries, expected revisions and content hashes connect historical originals to chat receipts without implicit retrieval. All four experiences share the same picker and source contract. Execution evidence belongs to the continuation PR; hosted qualification and the wider overhaul remain separate.
Expand Down
4 changes: 3 additions & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Taskdeck Status (Source of Truth)

Last Updated: 2026-09-09
Last Updated: 2026-09-10

Board proposal preview continuation (#2808): Chat and both Review layouts link to a checked read-only overlay on the existing board. Effective revision, proposal status/update time and board identity must match before projecting operation targets; execution parameter IDs take precedence over display IDs. Existing cards/columns are marked across Classic, Studio, Companion and Unified, while creation/hidden objects remain in the diff. Board refresh failures, changes, route/account transitions and bounded expiry retract markers. The surface adds no approval, Apply or board mutation. Component and real-API Chromium checks prove projection, non-mutation, Review navigation and mobile rendering; the wider overhaul remains open.

Original-source Companion continuation (#2808):

Expand Down
4 changes: 3 additions & 1 deletion docs/product/WORKSPACE_OVERHAUL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Switchable workspace overhaul

Last Updated: 2026-09-09
Last Updated: 2026-09-10

**Preview on board** opens a read-only proposal layer from Chat or Review. Choose **Refresh board preview** to check the current proposal revision and highlight saved cards/columns affected by its operations in either board renderer. The checked diff also describes new or hidden objects. The board continues to show saved data; **Open Review** returns to that exact proposal for approval and explicit Apply. Closing the preview, changing board data, losing access or reaching the short freshness limit removes its markers. This reduces the need to mentally match proposal changes to board objects while preserving review-first trust.

Companion's source picker also offers individual preserved originals beside each private memory.
Open **Choose original sources**, inspect the saved excerpt, then select the exact answer or evidence
Expand Down
7 changes: 7 additions & 0 deletions docs/product/WORKSPACE_OVERHAUL_VALIDATION.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
# Workspace overhaul validation and follow-through

## Board-object proposal overlays (2026-09-10)

`BoardProposalPreview.spec.ts` covers matched effective revisions, approved pins, mismatched board/revision/status/update receipts, parameter-ID precedence, immutable board inputs, permission/board refresh failure, stale in-flight results, logout, same-user token refresh, server-clock expiry and both card renderers. Existing Chat preview and Board view regressions also pass. Full frontend: 6,368 passed and three existing skips; subsequent projection/access changes have 49 focused passing tests plus typecheck. Exact final evidence is retained with the continuation PR.

`board-proposal-overlays.spec.ts` creates a real update/reorder/create proposal and checks both board renderers across all four experiences. It proves visible markers, saved titles, explicit close, unchanged saved cards/columns, no browser mutation requests, Review-to-board navigation, access refusal and 375 px/no serious-critical axe findings. The final journey passed in 15.8 seconds. Initial fixture failures omitted required operation parameters, requested an unsupported column action, and captured column card counts before seeding the card; each was corrected without relaxing product assertions. New objects remain described by the checked diff rather than simulated board state. No actual board Apply, production deployment or provider-quality acceptance is claimed.

## Audio review repairs (2026-09-10)

Audio drafts now bind to the board, card, question and revision present when file selection or microphone acquisition starts. Editing that question retains the local file for replay/download but prevents uploading it under new question evidence. A new draft receives the current binding. Two regressions cover edits before upload and while recording.

Production CSPs permit only same-origin and local `blob:` media; nginx and its AWS template permit same-origin microphone requests while continuing to deny camera and geolocation. API header checks, proxy policy contracts and Chromium probes exercise these exact policy strings. The probes load a local WAV and inspect effective microphone policy; they do not claim real microphone hardware acceptance. The retained-original library browser journey also passes.


## Original-source context continuation (2026-09-09)

`ChatOriginalContextApiTests.cs` extends the context API suite with original-only dispatch, exact
Expand Down
113 changes: 113 additions & 0 deletions frontend/taskdeck-web/src/components/board/BoardProposalPreview.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { onScopeDispose, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { automationApi } from '../../api/automationApi'
import { useSessionStore } from '../../store/sessionStore'
import { getErrorDisplay } from '../../composables/useErrorMapper'
import type { BoardProposalMarkers } from '../../composables/useBoardProposalMarker'
import { normalizeProposalStatus } from '../../utils/automation'
import type { BoardDetail, Card } from '../../types/board'
import type { Proposal, ProposalPreview } from '../../types/automation'

const props = withDefaults(defineProps<{ proposalId: string; board: BoardDetail; cards: Card[]; available?: boolean }>(), { available: true })
const emit = defineEmits<{ markers: [value: BoardProposalMarkers]; close: [] }>()
const session = useSessionStore()
const preview = ref<ProposalPreview | null>(null)
const proposal = ref<Proposal | null>(null)
const loading = ref(false)
const error = ref('')
let generation = 0
let timer: ReturnType<typeof setTimeout> | undefined
const sameId = (a: string | null, b: string | null) => a?.toLowerCase() === b?.toLowerCase()
function clear() {
generation++; clearTimeout(timer); preview.value = null; proposal.value = null
loading.value = false; error.value = ''; emit('markers', {})
}
function invalidate() {
clear(); error.value = 'The board or session changed. Refresh to check this proposal again.'
}
watch([() => props.proposalId, () => props.available, () => session.userId, () => !!session.token], invalidate, { flush: 'sync' })
watch([() => props.board, () => props.cards], invalidate, { deep: true, flush: 'sync' })

async function load() {
clear()
if (!props.available) return
const request = generation
const startedAt = performance.now()
loading.value = true
try {
const [receipt, detail] = await Promise.all([
automationApi.getProposalPreview(props.proposalId), automationApi.getProposal(props.proposalId),
])
if (request !== generation) return
const status = normalizeProposalStatus(detail.status)
const effectiveId = status === 'Approved' ? detail.approvedRevisionId : detail.latestRevisionId
if (!sameId(receipt.proposalId, props.proposalId) || !sameId(detail.id, props.proposalId)
|| !sameId(receipt.boardId, props.board.id) || !sameId(detail.boardId, props.board.id)
|| !sameId(receipt.effectiveRevisionId, effectiveId)
|| Date.parse(receipt.proposalUpdatedAt) !== Date.parse(detail.updatedAt)
|| normalizeProposalStatus(receipt.status) !== status
|| !['PendingReview', 'Approved'].includes(status))
throw new Error('The proposal changed while loading. Refresh to check its latest revision.')
const lifetime = Math.min(30000, Date.parse(receipt.expiresAt) - Date.parse(receipt.checkedAt)) - (performance.now() - startedAt)
if (!Number.isFinite(lifetime) || lifetime <= 0) throw new Error('This proposal preview has expired. Open Review for its history.')
const existing = new Set([
...props.cards.filter(card => sameId(card.boardId, props.board.id)).map(card => `card:${card.id.toLowerCase()}`),
...props.board.columns.map(column => `column:${column.id.toLowerCase()}`),
])
const markers: Record<string, string> = {}
for (const operation of detail.operations) {
const kind = operation.targetType.toLowerCase()
let parameters: Record<string, unknown> = {}
try { const parsed: unknown = JSON.parse(operation.parameters); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) parameters = parsed as Record<string, unknown> } catch { /* The checked diff remains available if a target cannot be projected. */ }
// Execution uses parameter IDs. A display targetId must never override them.
const parameterId = Object.entries(parameters).find(([name]) => name.toLowerCase() === `${kind}id`)?.[1]
const id = typeof parameterId === 'string' ? parameterId : operation.targetId
const key = `${kind}:${id?.toLowerCase()}`
if (operation.actionType.toLowerCase() !== 'create' && existing.has(key)) markers[key] = 'Proposed change'
if (kind === 'card' && ['create', 'move'].includes(operation.actionType.toLowerCase()) && typeof parameters.columnId === 'string') {
const destination = `column:${parameters.columnId.toLowerCase()}`
if (existing.has(destination)) markers[destination] = 'Proposed change'
}
}
proposal.value = detail; preview.value = receipt; emit('markers', markers)
timer = setTimeout(() => { clear(); error.value = 'Refresh the preview to check the latest changes.' }, lifetime)
} catch (cause) {
if (request === generation) error.value = getErrorDisplay(cause, 'Preview unavailable. Open Review to inspect this proposal.').message
} finally { if (request === generation) loading.value = false }
}
onScopeDispose(clear)
</script>

<template>
<section class="board-proposal-preview" aria-label="Board proposal preview">
<div class="board-proposal-preview__actions">
<h2>Proposed board changes</h2>
<button type="button" :disabled="loading || !available" @click="load">{{ loading ? 'Checking proposal…' : 'Refresh board preview' }}</button>
<RouterLink :to="{ path: '/workspace/review', query: { boardId: board.id }, hash: `#proposal-${proposalId}` }">Open Review</RouterLink>
<button type="button" @click="emit('close')">Close preview</button>
Comment on lines +85 to +88

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route the preview panel copy through locale catalogs

When Italian or Spanish is selected, this new board panel remains entirely in English because its labels and explanatory copy are hardcoded rather than read through vue-i18n. Board and Review are already extracted surfaces, and ADR-0054 requires future user-visible strings on them to be added to all three catalogs, so this introduces a mixed-language core flow. Add board/review catalog keys for the panel and the new Review links instead of embedding English literals.

Useful? React with 👍 / 👎.

</div>
<p v-if="error" role="status">{{ error }}</p>
<template v-if="preview && proposal">
<p>{{ normalizeProposalStatus(preview.status) }} · {{ preview.effectiveRevisionNumber === null ? 'Original proposal' : `Revision ${preview.effectiveRevisionNumber}` }} · checked {{ new Date(preview.checkedAt).toLocaleTimeString() }}</p>
<p>{{ proposal.presentation?.plainSummary || proposal.summary }}</p>
<details open><summary>Changes to inspect</summary><pre>{{ preview.diff }}</pre></details>
<p>Existing targets are marked “Proposed change”. New items, hidden cards and targets without a board object appear in the summary above. The board still shows its saved state. Approval and Apply remain in Review.</p>
</template>
<p v-else-if="!loading && !error">Check the proposal to reveal its diff and mark the affected board objects.</p>
</section>
</template>

<style scoped>
.board-proposal-preview { margin: 1rem; padding: 1rem; border: 2px dashed var(--td-border-default); border-radius: .75rem; background: var(--td-surface-primary); color: var(--td-text-primary); }
.board-proposal-preview__actions { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
h2 { font-size: 1rem; font-weight: 650; margin-right: auto; }
button, a { padding: .5rem; border: 1px solid var(--td-border-default); border-radius: .3rem; }
p { font-size: .85rem; margin-top: .5rem; line-height: 1.5; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 16rem; overflow: auto; font-size: .8rem; }
</style>

<style>
[data-proposal-change] { outline: 2px dashed var(--td-text-secondary); outline-offset: -2px; }
.td-proposal-marker { display: block; padding: .3rem .6rem; font-size: .75rem; font-weight: 650; color: var(--td-text-primary); background: var(--td-surface-secondary); border-bottom: 1px dashed var(--td-border-default); }
</style>
4 changes: 4 additions & 0 deletions frontend/taskdeck-web/src/components/board/CardItem.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useBoardProposalMarker } from '../../composables/useBoardProposalMarker'
import type { Card, Column } from '../../types/board'
import { formatCalendarDate, isCalendarDateOverdue } from '../../utils/dueDates'

Expand All @@ -17,6 +18,7 @@ const emit = defineEmits<{
}>()

const isDragging = ref(false)
const proposalMarker = useBoardProposalMarker('card', () => props.card.id)
const showMoveMenu = ref(false)

function toggleMoveMenu(event: Event) {
Expand Down Expand Up @@ -120,6 +122,7 @@ function isOverdue(dateString: string | null): boolean {
<div
draggable="false"
:data-card-id="card.id"
:data-proposal-change="proposalMarker ? true : undefined"
role="option"
:class="[
'td-board-card group relative cursor-pointer',
Expand All @@ -135,6 +138,7 @@ function isOverdue(dateString: string | null): boolean {
@dragend="handleDragEnd"
>
<!-- Ember leading-edge indicator -->
<span v-if="proposalMarker" class="td-proposal-marker">{{ proposalMarker }}</span>
<span class="td-board-card__indicator" aria-hidden="true" />

<!-- Card action bar: drag handle + move menu trigger -->
Expand Down
4 changes: 4 additions & 0 deletions frontend/taskdeck-web/src/components/board/ColumnLane.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, nextTick } from 'vue'
import { useBoardProposalMarker } from '../../composables/useBoardProposalMarker'
import { useBoardStore } from '../../store/boardStore'
import { useToastStore } from '../../store/toastStore'
import { getErrorDisplay } from '../../composables/useErrorMapper'
Expand Down Expand Up @@ -185,12 +186,14 @@ function handleCardDragOver(event: DragEvent) {
event.dataTransfer.dropEffect = 'move'
}
}
const proposalMarker = useBoardProposalMarker('column', () => props.column.id)
</script>

<template>
<!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag-and-drop column drop zone; group role + drag events are intentional for kanban DnD -->
<div
:data-column-id="column.id"
:data-proposal-change="proposalMarker ? true : undefined"
role="group"
:aria-label="`${column.name} column`"
:class="[
Expand All @@ -202,6 +205,7 @@ function handleCardDragOver(event: DragEvent) {
@drop="handleDrop"
>
<!-- Column Header -->
<span v-if="proposalMarker" class="td-proposal-marker">{{ proposalMarker }}</span>
<div class="td-column-lane__header">
<div class="td-column-lane__header-row">
<h3 class="td-column-lane__title"><span class="td-column-lane__title-dot"></span>{{ column.name }}</h3>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onScopeDispose, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { automationApi } from '../../api/automationApi'
import { useSessionStore } from '../../store/sessionStore'
import { getErrorDisplay } from '../../composables/useErrorMapper'
Expand Down Expand Up @@ -48,6 +49,7 @@ onScopeDispose(clear)
<template v-if="preview">
<p>{{ normalizeProposalStatus(preview.status) }} · {{ preview.effectiveRevisionNumber === null ? 'Original proposal' : `Revision ${preview.effectiveRevisionNumber}` }} · checked {{ new Date(preview.checkedAt).toLocaleTimeString() }}</p>
<pre>{{ preview.diff }}</pre>
<RouterLink v-if="boardId" :to="{ path: `/workspace/boards/${boardId}`, query: { proposalId } }">Preview on board</RouterLink>
<p>This is a checked preview, not an applied change. Open Review to inspect, approve and explicitly apply. Review checks the proposal again.</p>
</template>
</section>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { normalizeProposalStatus } from '../../utils/automation'
import type { Proposal, ProposalAffectedEntity } from '../../types/automation'

const props = withDefaults(defineProps<{
Expand Down Expand Up @@ -167,6 +168,13 @@ const fullCorrelationId = computed(() => props.proposal.correlationId?.trim() ??
>
Review Link
</router-link>
<router-link
v-if="proposal.boardId && !props.readOnly && ['PendingReview', 'Approved'].includes(normalizeProposalStatus(proposal.status))"
class="td-review-card__links-dropdown-item"
role="menuitem"
:to="{ path: `/workspace/boards/${proposal.boardId}`, query: { proposalId: proposal.id } }"
@mousedown.prevent
>Preview on board</router-link>
<button
v-if="proposal.boardId && !props.readOnly"
class="td-review-card__links-dropdown-item"
Expand Down
10 changes: 10 additions & 0 deletions frontend/taskdeck-web/src/composables/useBoardProposalMarker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { computed, inject, type InjectionKey, type Ref } from 'vue'

export type BoardProposalMarkers = Readonly<Record<string, string>>
export const BOARD_PROPOSAL_MARKERS: InjectionKey<Readonly<Ref<BoardProposalMarkers>>> = Symbol('board-proposal-markers')

/** Presentation only: a missing provider is the ordinary, unmodified board. */
export function useBoardProposalMarker(kind: 'card' | 'column', id: () => string) {
const markers = inject(BOARD_PROPOSAL_MARKERS, undefined)
return computed(() => markers?.value[`${kind}:${id().toLowerCase()}`])
}
Loading
Loading