tweaking the research implementation, notes now can be taken in trminal and active workspace selector should be functional

This commit is contained in:
2026-04-11 00:42:11 -04:00
parent 074c40416b
commit 534b154a37
14 changed files with 421 additions and 134 deletions

View File

@@ -293,9 +293,9 @@ function App() {
onClearPortfolioAction={handleClearPortfolioAction} onClearPortfolioAction={handleClearPortfolioAction}
resetCommandIndex={resetCommandIndex} resetCommandIndex={resetCommandIndex}
portfolioWorkflow={activePortfolioWorkflow} portfolioWorkflow={activePortfolioWorkflow}
onOpenResearchContext={(intent) => { researchWorkspaces={researchWorkspaces.workspaces}
void handleOpenResearch(intent); activeResearchWorkspaceId={researchWorkspaces.activeWorkspaceId}
}} onCaptureResearchNote={handleCaptureResearchNote}
/> />
)} )}
</div> </div>

View File

@@ -1,6 +1,20 @@
import { describe, expect, it } from 'bun:test'; import { describe, expect, it } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server'; import { renderToStaticMarkup } from 'react-dom/server';
import { ResearchMode } from './ResearchMode'; import { ResearchMode } from './ResearchMode';
import type { ResearchWorkspace } from '../../types/research';
const createWorkspace = (id: string, ticker: string): ResearchWorkspace => ({
id,
name: `${ticker} Research`,
primaryTicker: ticker,
scope: 'single_company',
stage: 'capture',
defaultView: 'canvas',
pinnedNoteIds: [],
archived: false,
createdAt: '2026-04-09T10:00:00Z',
updatedAt: '2026-04-09T10:00:00Z',
});
describe('ResearchMode', () => { describe('ResearchMode', () => {
it('renders empty state when no workspace is selected', () => { it('renders empty state when no workspace is selected', () => {
@@ -19,6 +33,25 @@ describe('ResearchMode', () => {
); );
expect(html).toContain('No research workspace selected'); expect(html).toContain('No research workspace selected');
expect(html).toContain('Quick capture'); expect(html).toContain('Select a research workspace');
});
it('renders the selected workspace details from the active workspace id', () => {
const html = renderToStaticMarkup(
<ResearchMode
workspaces={[createWorkspace('workspace-1', 'AAPL'), createWorkspace('workspace-2', 'NVDA')]}
activeWorkspaceId="workspace-2"
navigationIntent={null}
onSelectWorkspace={() => {}}
onEnsureWorkspace={async () => null}
onCaptureResearchNote={async () => {
throw new Error('not used');
}}
onConsumeNavigationIntent={() => {}}
/>,
);
expect(html).toContain('NVDA Research');
expect(html).toContain('NVDA');
}); });
}); });

View File

@@ -96,7 +96,12 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false); const [isLoadingAuditTrail, setIsLoadingAuditTrail] = useState(false);
const [isCaptureModalOpen, setIsCaptureModalOpen] = useState(false); const [isCaptureModalOpen, setIsCaptureModalOpen] = useState(false);
const [isCreateWorkspaceModalOpen, setIsCreateWorkspaceModalOpen] = useState(false); const [isCreateWorkspaceModalOpen, setIsCreateWorkspaceModalOpen] = useState(false);
const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<ResearchComposerState, 'rawText' | 'ticker'> | null>(null); const [terminalNoteSeed, setTerminalNoteSeed] = useState<Pick<
ResearchComposerState,
'rawText' | 'ticker'
> & {
contextLabel?: string;
} | null>(null);
useEffect(() => { useEffect(() => {
if (!activeWorkspaceId) { if (!activeWorkspaceId) {
@@ -137,6 +142,7 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
setTerminalNoteSeed({ setTerminalNoteSeed({
rawText: navigationIntent.terminalNoteSeed.rawText ?? '', rawText: navigationIntent.terminalNoteSeed.rawText ?? '',
ticker: navigationIntent.terminalNoteSeed.ticker ?? '', ticker: navigationIntent.terminalNoteSeed.ticker ?? '',
contextLabel: navigationIntent.terminalNoteSeed.contextLabel,
}); });
setIsCaptureModalOpen(true); setIsCaptureModalOpen(true);
} }
@@ -152,7 +158,14 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
]); ]);
const projection = projectionState.projection; const projection = projectionState.projection;
const currentWorkspace = projection?.workspace ?? workspaceState.workspace; const selectedWorkspaceRecord = useMemo(
() => workspaces.find((workspace) => workspace.id === activeWorkspaceId) ?? null,
[activeWorkspaceId, workspaces],
);
const currentWorkspace =
(projection?.workspace.id === activeWorkspaceId ? projection.workspace : null) ??
(workspaceState.workspace?.id === activeWorkspaceId ? workspaceState.workspace : null) ??
selectedWorkspaceRecord;
const filteredNotes = useMemo(() => { const filteredNotes = useMemo(() => {
const notes = projection?.notes ?? []; const notes = projection?.notes ?? [];
@@ -482,7 +495,10 @@ export const ResearchMode: React.FC<ResearchModeProps> = ({
defaultWorkspaceId={activeWorkspaceId} defaultWorkspaceId={activeWorkspaceId}
defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker} defaultTicker={currentWorkspace?.primaryTicker ?? navigationIntent?.ticker}
defaultRawText={terminalNoteSeed?.rawText} defaultRawText={terminalNoteSeed?.rawText}
contextLabel={currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture'} contextLabel={
terminalNoteSeed?.contextLabel ??
(currentWorkspace ? `${currentWorkspace.primaryTicker} workspace` : 'Quick capture')
}
onWorkspaceChange={onSelectWorkspace} onWorkspaceChange={onSelectWorkspace}
onSubmitCapture={(draft) => onSubmitCapture={(draft) =>
onCaptureResearchNote({ onCaptureResearchNote({

View File

@@ -300,7 +300,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
}`} }`}
title="Switch to Terminal (Cmd+T)" title="Switch to Terminal (Cmd+T)"
> >
<TerminalSquare className="h-4 w-4" /> {/* <TerminalSquare className="h-4 w-4" /> */}
Terminal Terminal
</button> </button>
<button <button
@@ -309,7 +309,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
className="flex items-center justify-center gap-2 border border-info bg-info/10 px-3 py-2 text-center text-xs font-mono text-info" className="flex items-center justify-center gap-2 border border-info bg-info/10 px-3 py-2 text-center text-xs font-mono text-info"
title="Research" title="Research"
> >
<NotebookPen className="h-4 w-4" /> {/* <NotebookPen className="h-4 w-4" /> */}
Research Research
</button> </button>
<button <button
@@ -321,7 +321,7 @@ export const ResearchSidebar: React.FC<ResearchSidebarProps> = ({
: "border-term-border bg-term-surface text-term-text-tertiary hover:border-info/60 hover:text-term-text" : "border-term-border bg-term-surface text-term-text-tertiary hover:border-info/60 hover:text-term-text"
}`} }`}
> >
<Settings className="h-4 w-4" /> {/* <Settings className="h-4 w-4" /> */}
Settings Settings
</button> </button>
</div> </div>

View File

@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { import {
ChevronLeft,
LayoutGrid, LayoutGrid,
Menu, Menu,
PanelLeft, PanelLeft,

View File

@@ -1,16 +1,22 @@
import React from 'react'; import React from "react";
import { Settings, ChevronLeft, Briefcase, TerminalSquare, NotebookPen } from 'lucide-react'; import {
import { PortfolioSummary } from './PortfolioSummary'; Settings,
import { TickerHistory } from './TickerHistory'; ChevronLeft,
import { Portfolio } from '../../types/financial'; Briefcase,
import { TickerHistoryEntry } from '../../types/terminal'; TerminalSquare,
NotebookPen,
} from "lucide-react";
import { PortfolioSummary } from "./PortfolioSummary";
import { TickerHistory } from "./TickerHistory";
import { Portfolio } from "../../types/financial";
import { TickerHistoryEntry } from "../../types/terminal";
interface SidebarProps { interface SidebarProps {
onCommand: (command: string) => void; onCommand: (command: string) => void;
onOpenTerminal: () => void; onOpenTerminal: () => void;
onOpenResearch: () => void; onOpenResearch: () => void;
onOpenSettings: () => void; onOpenSettings: () => void;
activeView: 'terminal' | 'research' | 'settings'; activeView: "terminal" | "research" | "settings";
isOpen: boolean; isOpen: boolean;
onToggle: () => void; onToggle: () => void;
tickerHistory: TickerHistoryEntry[]; tickerHistory: TickerHistoryEntry[];
@@ -53,7 +59,7 @@ export const Sidebar: React.FC<SidebarProps> = ({
<div className="flex-1 overflow-y-auto p-2 space-y-2"> <div className="flex-1 overflow-y-auto p-2 space-y-2">
{/* Portfolio icon */} {/* Portfolio icon */}
<button <button
onClick={() => onCommand('/portfolio')} onClick={() => onCommand("/portfolio")}
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative" className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
title="Portfolio" title="Portfolio"
> >
@@ -73,7 +79,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative" className="w-full aspect-square bg-term-highlight hover:bg-term-border rounded flex items-center justify-center transition-colors group relative"
title={entry.company.name} title={entry.company.name}
> >
<span className={`text-xs font-mono font-bold ${isPositive ? 'text-positive' : 'text-negative'}`}> <span
className={`text-xs font-mono font-bold ${isPositive ? "text-positive" : "text-negative"}`}
>
{entry.company.symbol.slice(0, 2)} {entry.company.symbol.slice(0, 2)}
</span> </span>
<span className="absolute left-full ml-2 px-2 py-1 bg-term-highlight border border-term-border rounded text-xs text-term-text opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none"> <span className="absolute left-full ml-2 px-2 py-1 bg-term-highlight border border-term-border rounded text-xs text-term-text opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
@@ -89,9 +97,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
<button <button
onClick={onOpenTerminal} onClick={onOpenTerminal}
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${ className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
activeView === 'terminal' activeView === "terminal"
? 'border-info bg-info/10 text-info' ? "border-info bg-info/10 text-info"
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text' : "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
}`} }`}
title="Open terminal" title="Open terminal"
> >
@@ -100,9 +108,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
<button <button
onClick={onOpenResearch} onClick={onOpenResearch}
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${ className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
activeView === 'research' activeView === "research"
? 'border-info bg-info/10 text-info' ? "border-info bg-info/10 text-info"
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text' : "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
}`} }`}
title="Open research" title="Open research"
> >
@@ -112,9 +120,9 @@ export const Sidebar: React.FC<SidebarProps> = ({
<button <button
onClick={onOpenSettings} onClick={onOpenSettings}
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${ className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
activeView === 'settings' activeView === "settings"
? 'border-info bg-info/10 text-info' ? "border-info bg-info/10 text-info"
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text' : "border-term-border bg-term-elevated text-term-text-tertiary hover:text-term-text"
}`} }`}
title="Open settings" title="Open settings"
> >
@@ -135,8 +143,12 @@ export const Sidebar: React.FC<SidebarProps> = ({
<div className="p-4 border-b border-term-border"> <div className="p-4 border-b border-term-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-lg font-mono font-bold text-term-text">MosaicIQ</h1> <h1 className="text-lg font-mono font-bold text-term-text">
<p className="text-[10px] text-term-text-tertiary font-mono">Financial Terminal v1.0</p> MosaicIQ
</h1>
<p className="text-[10px] text-term-text-tertiary font-mono">
Financial Terminal v1.0
</p>
</div> </div>
<button <button
onClick={onToggle} onClick={onToggle}
@@ -153,12 +165,15 @@ export const Sidebar: React.FC<SidebarProps> = ({
{/* Portfolio Summary */} {/* Portfolio Summary */}
<PortfolioSummary <PortfolioSummary
portfolio={portfolio} portfolio={portfolio}
onLoadPortfolio={() => onCommand('/portfolio')} onLoadPortfolio={() => onCommand("/portfolio")}
/> />
{/* Ticker History - shows only when loaded */} {/* Ticker History - shows only when loaded */}
{isTickerHistoryLoaded && ( {isTickerHistoryLoaded && (
<TickerHistory history={tickerHistory} onTickerClick={handleCompanyClick} /> <TickerHistory
history={tickerHistory}
onTickerClick={handleCompanyClick}
/>
)} )}
</div> </div>
@@ -168,42 +183,46 @@ export const Sidebar: React.FC<SidebarProps> = ({
<button <button
onClick={onOpenTerminal} onClick={onOpenTerminal}
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${ className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
activeView === 'terminal' activeView === "terminal"
? 'border-info bg-info/10 text-info' ? "border-info bg-info/10 text-info"
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text' : "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
}`} }`}
> >
<TerminalSquare className="h-4 w-4" /> {/* <TerminalSquare className="h-4 w-4" /> */}
<span>Terminal</span> <span>Terminal</span>
</button> </button>
<button <button
onClick={onOpenResearch} onClick={onOpenResearch}
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${ className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
activeView === 'research' activeView === "research"
? 'border-info bg-info/10 text-info' ? "border-info bg-info/10 text-info"
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text' : "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
}`} }`}
> >
<NotebookPen className="h-4 w-4" /> {/* <NotebookPen className="h-4 w-4" /> */}
<span>Research</span> <span>Research</span>
</button> </button>
<button <button
onClick={onOpenSettings} onClick={onOpenSettings}
className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${ className={`flex items-center justify-center gap-2 rounded border px-3 py-2 text-center text-xs font-mono transition-colors ${
activeView === 'settings' activeView === "settings"
? 'border-info bg-info/10 text-info' ? "border-info bg-info/10 text-info"
: 'border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text' : "border-term-border bg-term-elevated text-term-text-tertiary hover:border-info hover:text-term-text"
}`} }`}
title="Open settings" title="Open settings"
> >
<Settings className="h-4 w-4" /> {/* <Settings className="h-4 w-4" /> */}
<span>Settings</span> <span>Settings</span>
</button> </button>
</div> </div>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="text-[10px] text-term-text-tertiary font-mono text-right"> <div className="text-[10px] text-term-text-tertiary font-mono text-right">
Press <kbd className="px-1.5 py-0.5 bg-term-highlight rounded text-term-text">Cmd+B</kbd> to toggle Press{" "}
<kbd className="px-1.5 py-0.5 bg-term-highlight rounded text-term-text">
Cmd+B
</kbd>{" "}
to toggle
</div> </div>
</div> </div>
</div> </div>

View File

@@ -408,7 +408,7 @@ export const CommandInput = React.forwardRef<CommandInputHandle, CommandInputPro
<div className="relative"> <div className="relative">
{!actionComposerActive && shadowState ? ( {!actionComposerActive && shadowState ? (
<div className="pointer-events-auto absolute inset-x-0 bottom-[calc(100%+0.75rem)] z-40 overflow-hidden rounded-2xl border border-term-border bg-term-elevated shadow-[0_20px_50px_rgba(0,0,0,0.22)]"> <div className="pointer-events-auto absolute inset-x-0 bottom-[calc(100%+0.75rem)] z-40 overflow-visible rounded-2xl border border-term-border bg-term-elevated shadow-[0_20px_50px_rgba(0,0,0,0.22)]">
<div className="flex items-center justify-between border-b border-term-border-subtle bg-term-surface px-3 py-2"> <div className="flex items-center justify-between border-b border-term-border-subtle bg-term-surface px-3 py-2">
<span className="text-[9px] font-mono uppercase tracking-wider text-term-text-muted"> <span className="text-[9px] font-mono uppercase tracking-wider text-term-text-muted">
Command Shadow Command Shadow

View File

@@ -1,10 +1,15 @@
import React from 'react'; import React from 'react';
import { ResearchCaptureModal } from '../Research/ResearchCaptureModal';
import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow'; import { PortfolioWorkflowState } from '../../hooks/usePortfolioWorkflow';
import { extractResearchContext } from '../../lib/researchContext';
import { buildTerminalResearchNoteSeed } from '../../lib/terminalResearchNote';
import { import {
ResearchNavigationIntent, resolveTerminalCaptureWorkspaceId,
resolveTerminalResearchCaptureIntent,
} from '../../lib/terminalResearchCapture';
import {
NoteCaptureResult,
ResearchWorkspace,
} from '../../types/research'; } from '../../types/research';
import type { ResearchComposerState } from '../../hooks/useResearchComposer';
import { import {
PortfolioAction, PortfolioAction,
PortfolioActionDraft, PortfolioActionDraft,
@@ -29,7 +34,14 @@ interface TerminalProps {
onClearPortfolioAction: () => void; onClearPortfolioAction: () => void;
resetCommandIndex: () => void; resetCommandIndex: () => void;
portfolioWorkflow: PortfolioWorkflowState; portfolioWorkflow: PortfolioWorkflowState;
onOpenResearchContext: (intent: ResearchNavigationIntent) => void; researchWorkspaces: ResearchWorkspace[];
activeResearchWorkspaceId: string | null;
onCaptureResearchNote: (args: {
draft: ResearchComposerState;
fallbackTicker?: string;
explicitWorkspaceId?: string | null;
autoCreateFromTicker?: boolean;
}) => Promise<NoteCaptureResult>;
} }
export const Terminal: React.FC<TerminalProps> = ({ export const Terminal: React.FC<TerminalProps> = ({
@@ -44,66 +56,56 @@ export const Terminal: React.FC<TerminalProps> = ({
onClearPortfolioAction, onClearPortfolioAction,
resetCommandIndex, resetCommandIndex,
portfolioWorkflow, portfolioWorkflow,
onOpenResearchContext, researchWorkspaces,
activeResearchWorkspaceId,
onCaptureResearchNote,
}) => { }) => {
const researchContext = extractResearchContext(history); const [terminalCapture, setTerminalCapture] = React.useState<{
const [terminalNoteSeed, setTerminalNoteSeed] = React.useState<{
key: string; key: string;
rawText?: string; rawText?: string;
ticker?: string; ticker?: string;
contextLabel: string; contextLabel?: string;
} | null>(null); } | null>(null);
const focusResearchCapture = React.useCallback(() => { const defaultWorkspaceId = React.useMemo(
() =>
resolveTerminalCaptureWorkspaceId({
ticker: terminalCapture?.ticker,
workspaces: researchWorkspaces,
activeWorkspaceId: activeResearchWorkspaceId,
}),
[activeResearchWorkspaceId, researchWorkspaces, terminalCapture?.ticker],
);
const closeTerminalCapture = React.useCallback(() => {
setTerminalCapture(null);
requestAnimationFrame(() => { requestAnimationFrame(() => {
const element = document.getElementById('research-capture-input'); inputRef.current?.focusWithText('');
if (element instanceof HTMLElement) {
element.focus();
}
}); });
}, []); }, [inputRef]);
const handleTerminalSubmit = React.useCallback( const handleTerminalSubmit = React.useCallback(
(command: string) => { (command: string) => {
const normalized = command.trim().toLowerCase(); const researchIntent = resolveTerminalResearchCaptureIntent(command, history);
if (normalized === '/notes add') { if (researchIntent?.terminalNoteSeed) {
setTerminalNoteSeed({ setTerminalCapture({
key: `${Date.now()}-note-add`, key: `${Date.now()}-${command.trim().toLowerCase()}`,
ticker: researchContext?.ticker, rawText: researchIntent.terminalNoteSeed.rawText,
contextLabel: researchContext?.label ?? 'Quick note from terminal', ticker: researchIntent.terminalNoteSeed.ticker ?? researchIntent.ticker,
rawText: '', contextLabel: researchIntent.terminalNoteSeed.contextLabel,
}); });
focusResearchCapture();
return;
}
if (normalized === '/notes current') {
const seed = buildTerminalResearchNoteSeed(history);
setTerminalNoteSeed({
key: `${Date.now()}-note-current`,
ticker: seed?.ticker ?? researchContext?.ticker,
contextLabel: seed?.contextLabel ?? researchContext?.label ?? 'Current terminal context',
rawText:
seed?.rawText ??
'Current terminal context is not available yet.\n\nAnalyst note:',
});
focusResearchCapture();
return; return;
} }
onSubmit(command); onSubmit(command);
}, },
[focusResearchCapture, history, onSubmit, researchContext?.label, researchContext?.ticker], [history, onSubmit],
); );
const captureContextLabel = terminalNoteSeed?.contextLabel ?? researchContext?.label;
const captureTicker = terminalNoteSeed?.ticker ?? researchContext?.ticker;
const showResearchCapture = Boolean(terminalNoteSeed);
return ( return (
<div className="relative flex h-full min-w-0 flex-col overflow-hidden bg-term-bg"> <div className="relative flex h-full min-w-0 flex-col overflow-hidden bg-term-bg">
{/* Command Input */} {/* Command Input */}
<div className="relative z-30 flex-shrink-0 overflow-visible border-b border-term-border bg-term-bg p-6"> <div className="relative z-30 flex-shrink-0 border-b border-term-border bg-term-bg p-6" style={{ overflow: 'visible' }}>
<CommandInput <CommandInput
ref={inputRef} ref={inputRef}
onSubmit={handleTerminalSubmit} onSubmit={handleTerminalSubmit}
@@ -117,43 +119,6 @@ export const Terminal: React.FC<TerminalProps> = ({
portfolioDraft={portfolioWorkflow.draft} portfolioDraft={portfolioWorkflow.draft}
lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand} lastPortfolioCommand={portfolioWorkflow.lastPortfolioCommand}
/> />
{showResearchCapture ? (
<div className="mt-4 flex items-center gap-3 px-4 py-3 bg-term-highlight border border-info rounded">
<div className="flex-1">
<div className="text-xs font-mono text-term-text">
Research capture ready - click below to open capture modal
</div>
<div className="text-[10px] font-mono text-term-text-tertiary mt-0.5">
{captureContextLabel} {captureTicker ? `${captureTicker}` : ''}
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setTerminalNoteSeed(null)}
className="px-3 py-1.5 text-xs font-mono text-term-text-tertiary hover:text-term-text transition-colors"
>
Cancel
</button>
<button
type="button"
onClick={() => {
onOpenResearchContext({
preferredView: undefined,
terminalNoteSeed: {
rawText: terminalNoteSeed?.rawText,
ticker: captureTicker,
},
});
setTerminalNoteSeed(null);
}}
className="px-3 py-1.5 text-xs font-mono bg-info hover:brightness-110 text-term-bg rounded font-semibold transition-colors"
>
Open Capture
</button>
</div>
</div>
) : null}
</div> </div>
{/* Terminal Output */} {/* Terminal Output */}
@@ -163,6 +128,24 @@ export const Terminal: React.FC<TerminalProps> = ({
onRunCommand={onRunCommand} onRunCommand={onRunCommand}
onStartPortfolioAction={onStartPortfolioAction} onStartPortfolioAction={onStartPortfolioAction}
/> />
<ResearchCaptureModal
isOpen={terminalCapture !== null}
onClose={closeTerminalCapture}
workspaces={researchWorkspaces}
defaultWorkspaceId={defaultWorkspaceId}
defaultTicker={terminalCapture?.ticker}
defaultRawText={terminalCapture?.rawText}
seedKey={terminalCapture?.key}
contextLabel={terminalCapture?.contextLabel ?? 'Quick capture'}
onSubmitCapture={(draft) =>
onCaptureResearchNote({
draft,
fallbackTicker: terminalCapture?.ticker,
autoCreateFromTicker: Boolean(terminalCapture?.ticker),
})
}
/>
</div> </div>
); );
}; };

View File

@@ -2,6 +2,7 @@ import {
startTransition, startTransition,
useDeferredValue, useDeferredValue,
useEffect, useEffect,
useEffectEvent,
useReducer, useReducer,
useRef, useRef,
} from 'react'; } from 'react';
@@ -29,6 +30,7 @@ interface ProjectionState {
} }
type ProjectionAction = type ProjectionAction =
| { type: 'reset' }
| { type: 'load_started'; refresh: boolean } | { type: 'load_started'; refresh: boolean }
| { type: 'load_succeeded'; projection: WorkspaceProjection } | { type: 'load_succeeded'; projection: WorkspaceProjection }
| { type: 'load_failed'; error: string } | { type: 'load_failed'; error: string }
@@ -50,6 +52,8 @@ const projectionReducer = (
action: ProjectionAction, action: ProjectionAction,
): ProjectionState => { ): ProjectionState => {
switch (action.type) { switch (action.type) {
case 'reset':
return createProjectionState();
case 'load_started': case 'load_started':
return { return {
...state, ...state,
@@ -113,31 +117,51 @@ export const useResearchProjection = (
const [state, dispatch] = useReducer(projectionReducer, undefined, createProjectionState); const [state, dispatch] = useReducer(projectionReducer, undefined, createProjectionState);
const deferredView = useDeferredValue(view); const deferredView = useDeferredValue(view);
const refreshTimeoutRef = useRef<number | null>(null); const refreshTimeoutRef = useRef<number | null>(null);
const requestIdRef = useRef(0);
const previousWorkspaceIdRef = useRef<string | null>(workspaceId);
const loadProjection = useRef(async (refresh: boolean) => { const loadProjection = useEffectEvent(async (refresh: boolean) => {
if (!workspaceId) { const nextWorkspaceId = workspaceId;
if (!nextWorkspaceId) {
return; return;
} }
const requestId = ++requestIdRef.current;
dispatch({ type: 'load_started', refresh }); dispatch({ type: 'load_started', refresh });
try { try {
const projection = await researchBridge.getWorkspaceProjection({ const projection = await researchBridge.getWorkspaceProjection({
workspaceId, workspaceId: nextWorkspaceId,
view: deferredView, view: deferredView,
}); });
if (requestId !== requestIdRef.current) {
return;
}
dispatch({ type: 'load_succeeded', projection }); dispatch({ type: 'load_succeeded', projection });
} catch (loadError) { } catch (loadError) {
if (requestId !== requestIdRef.current) {
return;
}
dispatch({ dispatch({
type: 'load_failed', type: 'load_failed',
error: loadError instanceof Error ? loadError.message : String(loadError), error: loadError instanceof Error ? loadError.message : String(loadError),
}); });
} }
}).current; });
useEffect(() => { useEffect(() => {
if (!workspaceId) { if (!workspaceId) {
previousWorkspaceIdRef.current = null;
requestIdRef.current += 1;
dispatch({ type: 'reset' });
return; return;
} }
if (previousWorkspaceIdRef.current !== workspaceId) {
previousWorkspaceIdRef.current = workspaceId;
requestIdRef.current += 1;
dispatch({ type: 'reset' });
}
void loadProjection(false); void loadProjection(false);
}, [deferredView, loadProjection, workspaceId]); }, [deferredView, loadProjection, workspaceId]);
@@ -149,7 +173,7 @@ export const useResearchProjection = (
}; };
}, []); }, []);
const scheduleRefresh = useRef(() => { const scheduleRefresh = useEffectEvent(() => {
if (refreshTimeoutRef.current != null) { if (refreshTimeoutRef.current != null) {
window.clearTimeout(refreshTimeoutRef.current); window.clearTimeout(refreshTimeoutRef.current);
} }
@@ -158,7 +182,7 @@ export const useResearchProjection = (
void loadProjection(true); void loadProjection(true);
refreshTimeoutRef.current = null; refreshTimeoutRef.current = null;
}, 150); }, 150);
}).current; });
useEffect(() => { useEffect(() => {
if (!workspaceId) { if (!workspaceId) {

View File

@@ -28,6 +28,7 @@ export const useResearchWorkspace = ({
} }
let cancelled = false; let cancelled = false;
setWorkspace(null);
setIsLoadingWorkspace(true); setIsLoadingWorkspace(true);
void researchBridge.getResearchWorkspace(workspaceId).then((nextWorkspace) => { void researchBridge.getResearchWorkspace(workspaceId).then((nextWorkspace) => {
if (!cancelled) { if (!cancelled) {

View File

@@ -47,7 +47,11 @@ export const useResearchWorkspaces = ({
const next = await researchBridge.listResearchWorkspaces(); const next = await researchBridge.listResearchWorkspaces();
startTransition(() => { startTransition(() => {
setWorkspaces(next); setWorkspaces(next);
setActiveWorkspaceId((current) => current ?? next[0]?.id ?? null); // Always set activeWorkspaceId if it's null, even if it was previously set
setActiveWorkspaceId((current) => {
if (current) return current;
return next[0]?.id ?? null;
});
}); });
} catch (loadError) { } catch (loadError) {
setError(loadError instanceof Error ? loadError.message : String(loadError)); setError(loadError instanceof Error ? loadError.message : String(loadError));
@@ -60,11 +64,28 @@ export const useResearchWorkspaces = ({
void loadWorkspaces(); void loadWorkspaces();
}, [loadWorkspaces]); }, [loadWorkspaces]);
// Ensure activeWorkspaceId always points to a valid workspace
useEffect(() => {
if (activeWorkspaceId) {
const exists = workspaces.find((w) => w.id === activeWorkspaceId);
if (!exists && workspaces.length > 0) {
startTransition(() => {
setActiveWorkspaceId(workspaces[0].id);
});
}
}
}, [activeWorkspaceId, workspaces]);
useResearchEventSubscriptions({ useResearchEventSubscriptions({
onWorkspaceUpdate: ({ workspace }) => { onWorkspaceUpdate: ({ workspace }) => {
startTransition(() => { startTransition(() => {
setWorkspaces((current) => upsertWorkspaceRecord(current, workspace)); setWorkspaces((current) => upsertWorkspaceRecord(current, workspace));
setActiveWorkspaceId((current) => current ?? workspace.id); setActiveWorkspaceId((current) => {
// If we have a current active workspace, keep it
if (current) return current;
// Otherwise, use the updated workspace's ID
return workspace.id;
});
}); });
}, },
}); });

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from 'bun:test';
import {
resolveTerminalCaptureWorkspaceId,
resolveTerminalResearchCaptureIntent,
} from './terminalResearchCapture';
import type { TerminalEntry } from '../types/terminal';
import type { ResearchWorkspace } from '../types/research';
describe('resolveTerminalResearchCaptureIntent', () => {
const history: TerminalEntry[] = [
{
id: 'command-1',
type: 'command',
content: '/search NVDA',
timestamp: new Date('2026-04-09T10:00:00Z'),
},
{
id: 'panel-1',
type: 'panel',
content: {
type: 'company',
data: {
symbol: 'NVDA',
name: 'NVIDIA',
price: 900,
change: 12,
changePercent: 1.35,
marketCap: 2_000_000_000_000,
profile: {
sector: 'Semiconductors',
description: 'GPU leader.',
},
},
},
timestamp: new Date('2026-04-09T10:00:01Z'),
},
];
it('creates a direct-open research intent for quick notes', () => {
const intent = resolveTerminalResearchCaptureIntent('/notes add', history);
expect(intent).toEqual({
ticker: 'NVDA',
terminalNoteSeed: {
rawText: '',
ticker: 'NVDA',
contextLabel: 'NVDA company snapshot',
},
});
});
it('creates a seeded research intent for current terminal context', () => {
const intent = resolveTerminalResearchCaptureIntent('/notes current', history);
expect(intent?.ticker).toBe('NVDA');
expect(intent?.terminalNoteSeed?.ticker).toBe('NVDA');
expect(intent?.terminalNoteSeed?.contextLabel).toBe('NVDA company context');
expect(intent?.terminalNoteSeed?.rawText).toContain(
'Current terminal context: company snapshot for NVDA',
);
expect(intent?.terminalNoteSeed?.rawText).toContain('Analyst note:');
});
it('prefers a workspace whose primary ticker matches the terminal capture', () => {
const workspaces: ResearchWorkspace[] = [
{
id: 'workspace-aapl',
name: 'AAPL Research',
primaryTicker: 'AAPL',
scope: 'single_company',
stage: 'capture',
defaultView: 'canvas',
pinnedNoteIds: [],
archived: false,
createdAt: '2026-04-09T10:00:00Z',
updatedAt: '2026-04-09T10:00:00Z',
},
{
id: 'workspace-nvda',
name: 'NVDA Research',
primaryTicker: 'NVDA',
scope: 'single_company',
stage: 'capture',
defaultView: 'canvas',
pinnedNoteIds: [],
archived: false,
createdAt: '2026-04-09T10:00:00Z',
updatedAt: '2026-04-09T10:00:00Z',
},
];
expect(
resolveTerminalCaptureWorkspaceId({
ticker: 'nvda',
workspaces,
activeWorkspaceId: 'workspace-aapl',
}),
).toBe('workspace-nvda');
});
it('falls back to the active workspace when the terminal capture has no ticker', () => {
const workspaces: ResearchWorkspace[] = [
{
id: 'workspace-aapl',
name: 'AAPL Research',
primaryTicker: 'AAPL',
scope: 'single_company',
stage: 'capture',
defaultView: 'canvas',
pinnedNoteIds: [],
archived: false,
createdAt: '2026-04-09T10:00:00Z',
updatedAt: '2026-04-09T10:00:00Z',
},
];
expect(
resolveTerminalCaptureWorkspaceId({
workspaces,
activeWorkspaceId: 'workspace-aapl',
}),
).toBe('workspace-aapl');
});
});

View File

@@ -0,0 +1,66 @@
import type { ResearchNavigationIntent, ResearchWorkspace } from '../types/research';
import type { TerminalEntry } from '../types/terminal';
import { extractResearchContext } from './researchContext';
import { buildTerminalResearchNoteSeed } from './terminalResearchNote';
const FALLBACK_CURRENT_CONTEXT_TEXT =
'Current terminal context is not available yet.\n\nAnalyst note:';
export const resolveTerminalResearchCaptureIntent = (
command: string,
history: TerminalEntry[],
): ResearchNavigationIntent | null => {
const normalized = command.trim().toLowerCase();
const researchContext = extractResearchContext(history);
if (normalized === '/notes add') {
return {
ticker: researchContext?.ticker,
terminalNoteSeed: {
rawText: '',
ticker: researchContext?.ticker,
contextLabel: researchContext?.label ?? 'Quick note from terminal',
},
};
}
if (normalized === '/notes current') {
const seed = buildTerminalResearchNoteSeed(history);
const ticker = seed?.ticker ?? researchContext?.ticker;
return {
ticker,
terminalNoteSeed: {
rawText: seed?.rawText ?? FALLBACK_CURRENT_CONTEXT_TEXT,
ticker,
contextLabel:
seed?.contextLabel ?? researchContext?.label ?? 'Current terminal context',
},
};
}
return null;
};
export const resolveTerminalCaptureWorkspaceId = ({
ticker,
workspaces,
activeWorkspaceId,
}: {
ticker?: string;
workspaces: ResearchWorkspace[];
activeWorkspaceId?: string | null;
}): string | undefined => {
const normalizedTicker = ticker?.trim().toUpperCase();
if (normalizedTicker) {
return workspaces.find(
(workspace) => workspace.primaryTicker.trim().toUpperCase() === normalizedTicker,
)?.id;
}
if (activeWorkspaceId) {
return activeWorkspaceId;
}
return workspaces.length === 1 ? workspaces[0].id : undefined;
};

View File

@@ -596,6 +596,7 @@ export interface ResearchNavigationIntent {
terminalNoteSeed?: { terminalNoteSeed?: {
rawText?: string; rawText?: string;
ticker?: string; ticker?: string;
contextLabel?: string;
}; };
} }