fraim 2.0.163 → 2.0.165

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -539,10 +539,18 @@ function stripReviewHandoffBlocks(text) {
539
539
  .trim();
540
540
  }
541
541
 
542
+ function stripHubInjectedPromptBlocks(text) {
543
+ return String(text || '')
544
+ .replace(/(?:^|\n)\s*\[How to talk to me\][\s\S]*?(?=\n\s*\[[^\]\n]+\]|\n\s*#{1,6}\s|\n\s*[-*]\s|\n{2,}|$)/gi, '\n')
545
+ .replace(/(?:^|\n)\s*(?:[$/]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
546
+ .replace(/\n{3,}/g, '\n\n')
547
+ .trim();
548
+ }
549
+
542
550
  // R8: render markdown subset safely. HTML is escaped first.
543
551
  function formatEmployeeText(text) {
544
552
  if (!text) return '';
545
- const visibleText = stripReviewHandoffBlocks(text);
553
+ const visibleText = stripHubInjectedPromptBlocks(stripReviewHandoffBlocks(text));
546
554
  if (!visibleText) return '';
547
555
  // 1. Escape HTML entities.
548
556
  let s = visibleText
@@ -2084,7 +2092,7 @@ function stripStubReference(text) {
2084
2092
  }
2085
2093
 
2086
2094
  function surfaceText(role, text, conv) {
2087
- const raw = stripStubReference(text);
2095
+ const raw = stripHubInjectedPromptBlocks(stripStubReference(text));
2088
2096
  if (!raw) return '';
2089
2097
 
2090
2098
  if (role === 'manager') {
@@ -2207,20 +2215,13 @@ function ensureThreadMessageViewportObserver() {
2207
2215
  }
2208
2216
 
2209
2217
  function syncThreadMessageViewport() {
2210
- const panel = els['thread-panel'];
2211
2218
  const messages = els['messages'];
2212
- if (!panel || !messages || !panel.open) return;
2213
- const summary = panel.querySelector('summary');
2214
- const panelHeight = panel.clientHeight || panel.getBoundingClientRect().height || 0;
2215
- const summaryHeight = summary ? (summary.offsetHeight || summary.getBoundingClientRect().height || 0) : 0;
2216
- const available = Math.floor(panelHeight - summaryHeight - 2);
2217
- if (available > 0) {
2218
- messages.style.height = `${Math.max(120, available)}px`;
2219
- messages.style.maxHeight = `${Math.max(120, available)}px`;
2220
- } else {
2221
- messages.style.height = '';
2222
- messages.style.maxHeight = '';
2223
- }
2219
+ if (!messages) return;
2220
+ // Flex layout now owns the thread height: #thread-panel[open] grows to fill the
2221
+ // available real estate and #messages (flex child, overflow-y:auto) scrolls
2222
+ // internally. Clear any legacy inline sizing so CSS stays authoritative.
2223
+ messages.style.height = '';
2224
+ messages.style.maxHeight = '';
2224
2225
  }
2225
2226
 
2226
2227
  function scrollThreadAfterViewportSync(conv, runningShouldStickToBottom, shouldScrollForUpdate) {
@@ -2249,7 +2250,8 @@ function scrollThreadForReview(conv) {
2249
2250
  return;
2250
2251
  }
2251
2252
 
2252
- const target = [...nodes].reverse().find((node) =>
2253
+ const completion = host.querySelector('#review-completion');
2254
+ const target = completion || [...nodes].reverse().find((node) =>
2253
2255
  node.classList.contains('employee') || node.classList.contains('system')
2254
2256
  ) || nodes[nodes.length - 1];
2255
2257
 
@@ -2257,7 +2259,7 @@ function scrollThreadForReview(conv) {
2257
2259
  const targetRect = target.getBoundingClientRect();
2258
2260
  const currentTop = host.scrollTop;
2259
2261
  const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
2260
- const reviewOffset = Math.max(24, host.clientHeight * 0.16);
2262
+ const reviewOffset = completion ? 8 : Math.max(24, host.clientHeight * 0.16);
2261
2263
  const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
2262
2264
  host.scrollTo({ top: desiredTop, behavior: 'smooth' });
2263
2265
  }
@@ -2513,6 +2515,25 @@ function artifactLabel(artifact) {
2513
2515
  return artifact.label || artifact.name || artifact.path || artifact.url || 'artifact';
2514
2516
  }
2515
2517
 
2518
+ function artifactBasename(artifact) {
2519
+ const raw = artifact && (artifact.path || artifact.url || artifact.name || '');
2520
+ const cleaned = String(raw || '').split(/[?#]/)[0];
2521
+ return cleaned.split(/[\\/]/).filter(Boolean).pop() || '';
2522
+ }
2523
+
2524
+ function artifactDisplayLabel(artifact) {
2525
+ const label = artifactLabel(artifact);
2526
+ const base = artifactBasename(artifact);
2527
+ if (!base || label === base || String(label).includes(base)) return label;
2528
+ return `${label} (${base})`;
2529
+ }
2530
+
2531
+ function reviewArtifactSummary(handoff) {
2532
+ const artifacts = handoff && Array.isArray(handoff.artifacts) ? handoff.artifacts : [];
2533
+ if (!artifacts.length) return '';
2534
+ return artifacts.map(artifactDisplayLabel).join('; ');
2535
+ }
2536
+
2516
2537
  function artifactLocalPath(artifact) {
2517
2538
  if (!artifact) return '';
2518
2539
  const raw = artifact.path
@@ -2564,7 +2585,7 @@ function deriveFormatFromReviewHandoff(handoff) {
2564
2585
  const actions = artifacts.length > 0
2565
2586
  ? artifacts.map((artifact, index) => ({
2566
2587
  id: `review-artifact-${index}`,
2567
- label: artifacts.length === 1 ? `Review ${artifact.label}` : artifact.label,
2588
+ label: artifactDisplayLabel(artifact),
2568
2589
  primary: index === 0,
2569
2590
  kind: artifactActionKind(artifact),
2570
2591
  artifact,
@@ -2628,7 +2649,7 @@ function deriveDeliverableFormat(conv) {
2628
2649
  key: 'markdown',
2629
2650
  label: artifactName || artifactLabel(artifact),
2630
2651
  actions: [
2631
- { id: 'download-docx', label: 'Download as .docx', primary: true, kind: 'export', artifact },
2652
+ { id: 'open-artifact', label: artifactDisplayLabel(artifact), primary: true, kind: 'doc', artifact },
2632
2653
  { id: 'inline-feedback', label: 'Type feedback inline', primary: false, kind: 'feedback', artifact },
2633
2654
  ],
2634
2655
  };
@@ -2649,7 +2670,7 @@ function deriveDeliverableFormat(conv) {
2649
2670
  // read-side already gives us and degrade gracefully when a field is unknown.
2650
2671
  function stripMarkdownForDisplay(text) {
2651
2672
  if (!text) return text;
2652
- return stripReviewHandoffBlocks(text)
2673
+ return stripHubInjectedPromptBlocks(stripReviewHandoffBlocks(text))
2653
2674
  .replace(/\*\*(.+?)\*\*/g, '$1')
2654
2675
  .replace(/\*(.+?)\*/g, '$1')
2655
2676
  .replace(/^---+$/gm, '')
@@ -2678,8 +2699,10 @@ function buildReviewCardRows(conv, fmt) {
2678
2699
  : `Completed "${conv.title || conv.jobTitle || 'the assigned work'}".`;
2679
2700
  return [
2680
2701
  { k: 'What changed', v: whatChanged },
2681
- reliableLabel
2682
- ? { k: 'Files updated', v: `${fmt.label}` }
2702
+ handoff && handoff.reviewTarget && handoff.reviewTarget.type === 'artifact_set'
2703
+ ? { k: 'Files updated', v: reviewArtifactSummary(handoff) || `${fmt.label}` }
2704
+ : reliableLabel
2705
+ ? { k: 'Files updated', v: `${fmt.label}` }
2683
2706
  : { k: 'Where to look', v: 'Open the updated files in your project (the Brief section shows the project context & rules) to review.' },
2684
2707
  { k: 'What to do next', v: 'Approve, or type what to change in the Coach box below.' },
2685
2708
  ];
@@ -2687,15 +2710,27 @@ function buildReviewCardRows(conv, fmt) {
2687
2710
  const whatIDid = lastEmployee
2688
2711
  ? clampSummaryText(stripMarkdownForDisplay(lastEmployee), 220)
2689
2712
  : `Completed “${conv.title || conv.jobTitle || 'the assigned work'}”.`;
2713
+ if (handoff && handoff.reviewTarget && handoff.reviewTarget.type === 'artifact_set') {
2714
+ return [
2715
+ { k: 'Files to review', v: reviewArtifactSummary(handoff) || `${fmt.label}` },
2716
+ { k: 'What I need', v: 'Your review — approve, or tell me what to sharpen.' },
2717
+ ];
2718
+ }
2690
2719
  const rows = [{ k: 'What I did', v: whatIDid }];
2691
- if (reliableLabel) rows.push({ k: 'Deliverable', v: `${fmt.label}` });
2720
+ if (reliableLabel) {
2721
+ rows.push({ k: 'Deliverable', v: `${fmt.label}` });
2722
+ }
2692
2723
  rows.push({ k: 'What I need', v: 'Your review — approve, or tell me what to sharpen.' });
2693
2724
  return rows;
2694
2725
  }
2695
2726
 
2696
- // The completion card lives as the LAST child of #messages so the thread
2697
- // panel's flex/scroll geometry is unchanged (it scrolls with the thread, like
2698
- // any employee turn). Find-or-create keeps it idempotent across poll ticks.
2727
+ // The completion card is a decision surface, not chat history. Keep it outside
2728
+ // the collapsible thread so the exact deliverables remain visible even when the
2729
+ // manager minimizes the conversation transcript.
2730
+ // The "Ready for your review" card is the employee's review turn, so it lives as
2731
+ // the LAST child of #messages — inside the manager/employee thread. Keeping it in
2732
+ // the thread (rather than as a separate panel below it) means it scrolls with the
2733
+ // conversation and the thread does not need to collapse to surface it.
2699
2734
  function reviewCompletionHost() {
2700
2735
  const messages = els['messages'];
2701
2736
  if (!messages) return null;
@@ -2713,6 +2748,12 @@ function clearReviewCompletion() {
2713
2748
  const messages = els['messages'];
2714
2749
  const existing = messages && messages.querySelector('#review-completion');
2715
2750
  if (existing) existing.remove();
2751
+ if (messages) {
2752
+ messages.classList.remove('review-focused');
2753
+ messages.querySelectorAll('.review-source-message').forEach((node) => {
2754
+ node.classList.remove('review-source-message');
2755
+ });
2756
+ }
2716
2757
  }
2717
2758
 
2718
2759
  function renderReviewExperience(conv) {
@@ -2792,9 +2833,9 @@ function renderReviewExperience(conv) {
2792
2833
  }
2793
2834
  }
2794
2835
 
2795
- // Comments follow the artifact (R7.7): a PR opens GitHub; markdown previews /
2796
- // exports; report views inline. Nothing new is invented these surface what the
2797
- // read-side already knows and otherwise leave a status hint.
2836
+ // Comments follow the artifact (R7.7): a PR opens GitHub; local artifacts open
2837
+ // from disk; reports view inline. Nothing new is invented - these surface what
2838
+ // the read-side already knows and otherwise leave a status hint.
2798
2839
  async function handleArtifactAction(conv, action) {
2799
2840
  const artifact = action.artifact || ((conv && conv.artifacts && conv.artifacts[0]) || null);
2800
2841
  if (action.kind === 'github') {
@@ -2838,55 +2879,6 @@ async function handleArtifactAction(conv, action) {
2838
2879
  }
2839
2880
  return;
2840
2881
  }
2841
- if (action.kind === 'export') {
2842
- // Download the deliverable as .docx for Word annotation. Try the on-disk file
2843
- // first; if it doesn't actually exist (404 — e.g. the employee named a file it
2844
- // never created) or there is no path, fall back to exporting the employee's
2845
- // written deliverable text. Either way we download a real .docx, never a JSON
2846
- // error. Feedback comes back through the Coach box (no "Done reviewing").
2847
- const artifactPath = artifact && (artifact.path || artifact.where || artifact.url);
2848
- const hasLocalFile = artifactPath && !String(artifactPath).startsWith('http');
2849
- const baseName = ((artifact && artifact.name) || conversationTitle(conv) || 'deliverable');
2850
- const triggerDownload = (blob) => {
2851
- const url = URL.createObjectURL(blob);
2852
- const a = document.createElement('a');
2853
- a.href = url;
2854
- a.download = String(baseName).replace(/\.[^.]+$/, '') + '.docx';
2855
- a.style.display = 'none';
2856
- document.body.appendChild(a);
2857
- a.click();
2858
- document.body.removeChild(a);
2859
- URL.revokeObjectURL(url);
2860
- };
2861
- const isDocx = (resp) => (resp.headers.get('content-type') || '').includes('word');
2862
- try {
2863
- let blob = null;
2864
- if (hasLocalFile) {
2865
- const resp = await fetch('/api/ai-hub/artifact/export-docx?path=' + encodeURIComponent(artifactPath));
2866
- if (resp.ok && isDocx(resp)) blob = await resp.blob();
2867
- // else: file missing on disk → fall through to the inline export below.
2868
- }
2869
- if (!blob) {
2870
- const inline = (typeof latestEmployeeSurfaceText === 'function' && latestEmployeeSurfaceText(conv)) || '';
2871
- if (!inline.trim()) {
2872
- showStatus('Nothing to download yet — the employee has not produced a file or a written deliverable.', true);
2873
- return;
2874
- }
2875
- const resp = await fetch('/api/ai-hub/artifact/export-docx', {
2876
- method: 'POST',
2877
- headers: { 'Content-Type': 'application/json' },
2878
- body: JSON.stringify({ content: inline, filename: baseName }),
2879
- });
2880
- if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.error || 'Export failed.'); }
2881
- blob = await resp.blob();
2882
- }
2883
- triggerDownload(blob);
2884
- showStatus('Downloaded as .docx — mark it up in Word, then type your feedback in the Coach box below to send changes back.', false);
2885
- } catch (err) {
2886
- showStatus(err instanceof Error ? err.message : 'Could not export the deliverable.', true);
2887
- }
2888
- return;
2889
- }
2890
2882
  // Any other action: feedback goes through the Coach box.
2891
2883
  showStatus('Type your feedback in the Coach box below — it becomes a coaching message for the employee.', false);
2892
2884
  }
@@ -1,5 +1,7 @@
1
1
  :root {
2
2
  color-scheme: light dark;
3
+ /* #ai-hub-polish: allow block-size:auto to animate (accordion open/close). */
4
+ interpolate-size: allow-keywords;
3
5
  /* Light theme — Apple-style near-white */
4
6
  --bg: #f5f5f7;
5
7
  --surface: #ffffff;
@@ -855,6 +857,11 @@ img.conv-employee-avatar {
855
857
  flex-shrink: 0;
856
858
  }
857
859
  .panel-details[open] > summary::before { transform: rotate(90deg); }
860
+ .panel-details:not([open]) > .panel-body,
861
+ .panel-details:not([open]) > .messages,
862
+ .panel-details:not([open]) > .micro-log {
863
+ display: none;
864
+ }
858
865
  .panel-summary-copy {
859
866
  display: flex;
860
867
  flex-direction: column;
@@ -1314,13 +1321,18 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
1314
1321
 
1315
1322
  .micro {
1316
1323
  margin-top: 0;
1324
+ border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
1325
+ background: color-mix(in srgb, var(--accent-soft) 48%, var(--soft));
1317
1326
  /* Removed: position:sticky / bottom:0 / z-index:2 — sticky caused the
1318
1327
  micro-manage label to float over the coach section in the flex column.
1319
1328
  It's now a normal flow element at the bottom of the support-stack. */
1320
1329
  }
1330
+ .micro .panel-kicker {
1331
+ color: var(--accent-strong);
1332
+ }
1321
1333
  .micro summary {
1322
1334
  cursor: pointer;
1323
- color: var(--muted);
1335
+ color: var(--text);
1324
1336
  font-size: 14px;
1325
1337
  list-style: none;
1326
1338
  display: flex;
@@ -2404,7 +2416,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2404
2416
  .syn-row { display: flex; align-items: flex-start; gap: 14px; padding: 12px 18px; border-bottom: 1px solid var(--line); }
2405
2417
  .syn-row:last-child { border-bottom: none; }
2406
2418
  .syn-label { font-size: 12px; font-weight: 600; color: var(--muted); width: 90px; flex-shrink: 0; padding-top: 1px; }
2407
- .syn-val { flex: 1; font-size: 13px; color: var(--text); line-height: 1.5; }
2419
+ .syn-val { flex: 1; min-width: 0; overflow-wrap: break-word; font-size: 13px; color: var(--text); line-height: 1.5; }
2408
2420
  .syn-edit { font-size: 12px; color: var(--accent); font-weight: 500; background: none; border: none; cursor: pointer; flex-shrink: 0; }
2409
2421
 
2410
2422
  /* Learnings */
@@ -2467,7 +2479,22 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2467
2479
  .ctx-acc > summary .ca-chev { font-size: 10px; color: var(--muted); transition: transform .15s; }
2468
2480
  .ctx-acc[open] > summary .ca-chev { transform: rotate(90deg); }
2469
2481
  .ctx-acc > summary .ca-note { font-weight: 400; text-transform: none; letter-spacing: 0; font-size: 11px; color: var(--muted); }
2470
- .ctx-acc-body { padding: 4px 14px 14px; }
2482
+ /* #ai-hub-polish F2: a long section must not bury the others. Cap the body and
2483
+ let it scroll internally so every accordion header stays reachable. */
2484
+ .ctx-acc-body { padding: 4px 14px 14px; max-height: min(62vh, 620px); overflow-y: auto; }
2485
+
2486
+ /* #ai-hub-polish F1: ease the open/close instead of snapping. Animate the
2487
+ <details> content slot; interpolate-size lets block-size animate to/from auto.
2488
+ @supports keeps older engines on the instant (pre-polish) behavior, and the
2489
+ prefers-reduced-motion block above neutralizes the transition. */
2490
+ @supports selector(::details-content) {
2491
+ .ctx-acc::details-content {
2492
+ block-size: 0;
2493
+ overflow: clip;
2494
+ transition: block-size .24s ease, content-visibility .24s allow-discrete;
2495
+ }
2496
+ .ctx-acc[open]::details-content { block-size: auto; }
2497
+ }
2471
2498
 
2472
2499
  /* #521: project-onboarding loop banner inside the Brief — "team is processing /
2473
2500
  here's their understanding to review". */
@@ -2639,26 +2666,115 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2639
2666
  padding: 16px 18px;
2640
2667
  }
2641
2668
 
2642
- /* Conversation view (full height): thread gets priority with a usable floor and
2643
- scrolls its messages internally; the support-stack stacks naturally and scrolls
2644
- on its own when both coach + micro-manage are open (it must NOT be a height-
2645
- distributing grid, or it clips the open panels the original bug). */
2669
+ /* Conversation view (full height): the open panels SHARE the available real
2670
+ estate instead of being pinned to fixed caps. Each open panel grows to fill its
2671
+ slice and scrolls internally when its content overflows (its own scrollbar).
2672
+ #active-conv keeps overflow-y:auto only as a fallback for very short windows
2673
+ where the panels' min-heights can't all fit. */
2646
2674
  .workspace-conv #active-conv {
2675
+ overflow-y: auto;
2676
+ display: flex;
2677
+ flex-direction: column;
2678
+ gap: 8px;
2679
+ min-height: 0;
2680
+ }
2681
+ .workspace-conv #active-conv > .conv-topline,
2682
+ .workspace-conv #active-conv > .conversation-status { flex: 0 0 auto; }
2683
+
2684
+ /* Manager/employee thread — the primary conversation. The "Ready for review"
2685
+ card lives inside #messages, so it scrolls with the thread. Open thread grows
2686
+ to fill spare height; collapsed it is just its summary bar. */
2687
+ .workspace-conv #thread-panel { flex: 0 0 auto; min-height: 0; }
2688
+ /* Open thread is the sole space-filler: it grows to take all height the support
2689
+ stack does not need, and scrolls #messages internally. Collapsed it is just
2690
+ its summary bar. */
2691
+ .workspace-conv #thread-panel[open] {
2692
+ flex: 1 1 0;
2693
+ display: flex;
2694
+ flex-direction: column;
2695
+ min-height: 120px;
2696
+ }
2697
+ .workspace-conv #thread-panel > summary { flex: 0 0 auto; }
2698
+ /* Chromium wraps <details> content in a ::details-content slot, so #messages is
2699
+ not a direct flex child of the panel. Make the slot a column flex container
2700
+ that fills the panel; then #messages can flex-grow and scroll inside it. */
2701
+ .workspace-conv #thread-panel[open]::details-content {
2702
+ flex: 1 1 0;
2703
+ min-height: 0;
2704
+ display: flex;
2705
+ flex-direction: column;
2647
2706
  overflow: hidden;
2648
- display: grid;
2649
- grid-template-rows: auto auto minmax(200px, 1fr) auto;
2650
2707
  }
2651
- .workspace-conv #thread-panel[open] { min-height: 0; display: flex; flex-direction: column; }
2652
- .workspace-conv #thread-panel > summary { flex-shrink: 0; }
2653
- .workspace-conv #thread-panel #messages { flex: 1; min-height: 0; overflow-y: auto; }
2708
+ .workspace-conv #thread-panel[open] #messages {
2709
+ flex: 1 1 0;
2710
+ min-height: 0;
2711
+ max-height: none;
2712
+ overflow-y: auto;
2713
+ }
2714
+ .workspace-conv #thread-panel:not([open]) #messages { display: none; }
2715
+
2716
+ /* Support stack: Coach (input) + Micro-manage (log). It sizes to its content and
2717
+ never grows past it, so the thread keeps the spare real estate. Each open body
2718
+ has a viewport-relative cap and scrolls internally, so it shows more on bigger
2719
+ screens (and far more than the old 1-line peek) without crowding the thread. */
2654
2720
  .workspace-conv .support-stack {
2655
- display: block; /* override base display:grid so panels stack, not clip */
2721
+ display: flex;
2722
+ flex-direction: column;
2723
+ gap: 8px;
2656
2724
  min-height: 0;
2657
- max-height: 48vh; /* bounded; scrolls internally if both panels open */
2725
+ flex: 0 1 auto;
2726
+ }
2727
+ .workspace-conv .support-stack > .panel-details--coach { flex: 0 0 auto; }
2728
+ .workspace-conv .support-stack > .panel-details--coach[open] > .panel-body {
2729
+ max-height: clamp(140px, 24vh, 260px);
2730
+ overflow-y: auto;
2731
+ }
2732
+ .workspace-conv .panel-details--coach .panel-body { padding-bottom: 8px; }
2733
+ .workspace-conv .coach-input textarea {
2734
+ min-height: 44px;
2735
+ padding-top: 9px;
2736
+ padding-bottom: 9px;
2737
+ }
2738
+ .workspace-conv .coach-note { display: none; }
2739
+ .workspace-conv .quick-coach-row { gap: 5px; margin-bottom: 8px; }
2740
+ .workspace-conv .quick-coach-btn { padding: 4px 9px; }
2741
+
2742
+ .workspace-conv .support-stack > .micro { flex: 0 0 auto; min-height: 0; }
2743
+ .workspace-conv .support-stack > .micro[open] > .micro-log {
2744
+ max-height: clamp(120px, 24vh, 260px);
2658
2745
  overflow-y: auto;
2659
2746
  }
2660
2747
  .workspace-conv .support-stack > .panel-details,
2661
- .workspace-conv .support-stack > .micro { margin-bottom: 8px; }
2748
+ .workspace-conv .support-stack > .micro { margin-bottom: 0; }
2749
+
2750
+ @media (max-width: 820px) {
2751
+ /* On narrow screens drop the fill model: panels flow naturally and the page
2752
+ scrolls, with each open body keeping a viewport-relative scroll cap. */
2753
+ .workspace-conv #active-conv {
2754
+ display: flex;
2755
+ flex-direction: column;
2756
+ overflow: visible;
2757
+ }
2758
+ .workspace-conv #thread-panel[open],
2759
+ .workspace-conv .support-stack,
2760
+ .workspace-conv .support-stack:has(.micro[open]),
2761
+ .workspace-conv .support-stack > .micro[open] {
2762
+ flex: 0 0 auto;
2763
+ display: block;
2764
+ min-height: 0;
2765
+ }
2766
+ .workspace-conv #thread-panel[open] #messages,
2767
+ .workspace-conv .support-stack > .micro[open] > .micro-log {
2768
+ flex: initial;
2769
+ height: auto;
2770
+ max-height: clamp(140px, 32vh, 280px);
2771
+ overflow-y: auto;
2772
+ }
2773
+ .workspace-conv .support-stack > .panel-details[open] > .panel-body {
2774
+ max-height: clamp(140px, 32vh, 280px);
2775
+ overflow-y: auto;
2776
+ }
2777
+ }
2662
2778
 
2663
2779
  @media (max-width: 640px) {
2664
2780
  body.hub-shell { overflow-x: hidden; }
@@ -2885,6 +3001,39 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2885
3001
  .workspace-conv > .page { overflow: hidden; }
2886
3002
  .workspace-conv .layout { display: flex; gap: 0; min-height: 0; flex: 1; overflow: hidden; }
2887
3003
 
3004
+ @media (max-width: 820px) {
3005
+ body.hub-shell {
3006
+ height: auto;
3007
+ min-height: 100vh;
3008
+ overflow: auto;
3009
+ }
3010
+ .hub-area,
3011
+ .area-main,
3012
+ #proj-workspace,
3013
+ .workspace-conv,
3014
+ .workspace-conv > .page,
3015
+ .workspace-conv .layout {
3016
+ overflow: visible;
3017
+ }
3018
+ }
3019
+
3020
+ /* #ai-hub-polish F3: Company & Manager areas had no narrow-width layout — the
3021
+ fixed 244px rail pushed the main column off the right edge (horizontal scroll).
3022
+ Below the Projects-workspace breakpoint, stack the rail above the content and
3023
+ let the page shrink so nothing overflows. 2-column stays intact on tablets. */
3024
+ @media (max-width: 640px) {
3025
+ body.hub-shell { overflow-x: hidden; }
3026
+ .area-shell { flex-direction: column; }
3027
+ .area-rail {
3028
+ width: 100%;
3029
+ flex-shrink: 0;
3030
+ max-height: 200px;
3031
+ border-right: none;
3032
+ border-bottom: 1px solid var(--line);
3033
+ }
3034
+ .hub-area-page { padding: 22px 16px 40px; min-width: 0; max-width: 100%; }
3035
+ }
3036
+
2888
3037
 
2889
3038
  /* ── #512 Round 2: actual class names from rendered HTML ────────────────── */
2890
3039
 
@@ -2899,8 +3048,16 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
2899
3048
  .syn-row { display: flex; align-items: flex-start; gap: 14px; padding: 13px 18px; border-bottom: 1px solid var(--line); }
2900
3049
  .syn-row:last-child { border-bottom: none; }
2901
3050
  .syn-label { font-size: 12px; font-weight: 600; color: var(--muted); width: 88px; flex-shrink: 0; padding-top: 2px; }
2902
- .syn-val { flex: 1; font-size: 13px; color: var(--text); line-height: 1.5; }
3051
+ .syn-val { flex: 1; min-width: 0; overflow-wrap: break-word; font-size: 13px; color: var(--text); line-height: 1.5; }
2903
3052
  .syn-edit { font-size: 12px; color: var(--accent); font-weight: 500; background: none; border: none; cursor: pointer; flex-shrink: 0; }
3053
+ /* #ai-hub-polish: at phone widths the fixed label column starves the value and
3054
+ forces mid-word wraps. Stack label over value so the value gets full width.
3055
+ Placed after the duplicate .syn-* block above so source order lets it win. */
3056
+ @media (max-width: 560px) {
3057
+ .syn-row { flex-wrap: wrap; gap: 4px 12px; }
3058
+ .syn-label { width: 100%; }
3059
+ .syn-val { flex: 1 1 100%; }
3060
+ }
2904
3061
 
2905
3062
  /* Issue #512 R3/R8 — Team Context inline editor. */
2906
3063
  /* ctx-content is now a div with rendered markdown (formatEmployeeText), not a raw <pre> */