Skip to content

Add FileChooser InputType to the Interaction Service#14882

Open
mcumming wants to merge 6 commits intodotnet:mainfrom
mcumming:feature/filechooser-interaction-input
Open

Add FileChooser InputType to the Interaction Service#14882
mcumming wants to merge 6 commits intodotnet:mainfrom
mcumming:feature/filechooser-interaction-input

Conversation

@mcumming
Copy link

@mcumming mcumming commented Mar 3, 2026

Summary

Add a new FileChooser input type to the Aspire Interaction Service, enabling users to select files via the native OS/browser file picker dialog within interaction prompts. The file content is streamed into memory and passed as the input Value, with the original filename preserved for display.

Screenshot

FileChooser dialog in the Aspire Dashboard

The FileChooser input renders as a read-only text field with a Browse button that opens the native file picker.

Motivation

The interaction service currently supports text, secret text, choice, boolean, and number input types. However, there is no way for users to select or upload files through interaction dialogs. This is needed for scenarios such as importing configuration files, uploading certificates, or processing user-provided data files.

Changes

Aspire.Hosting (Server-side model)

  • Added FileChooser member to the InputType enum
  • Added validation handling for FileChooser inputs in InteractionService.RunValidationAsync (required/empty checks, no additional format constraints)
  • Updated the public API surface (Aspire.Hosting.cs)

Proto/gRPC contract

  • Added INPUT_TYPE_FILE_CHOOSER = 6 to the InputType enum in dashboard_service.proto

Dashboard Service (Mapping layer)

  • Added bidirectional FileChooser mapping in both MapInputType methods in DashboardService.cs

Dashboard UI (Blazor frontend)

  • Added FileChooser case in InteractionsInputDialog.razor rendering a read-only FluentTextField (showing the selected filename) alongside a FluentInputFile component with a Browse button
  • The file is read into memory via InputFileMode.Stream — no temp files are written to disk
  • Added FileDisplayName property to InputViewModel to display the original filename while Value holds the file content
  • Added CSS styles for the file-chooser-container layout
  • Added localization strings for the placeholder and browse button text (Dialogs.resx)

CLI (Aspire.Cli)

  • Added FileChooser to the CLI's InputType enum in ExtensionBackchannelDataTypes.cs
  • Added FileChooser case in PipelineCommandBase.cs — falls back to a text prompt for entering a file path in CLI mode

New Playground project

  • Created playground/Interactions/ — a dedicated playground project for testing interaction service enhancements
  • Includes four sample commands demonstrating the FileChooser input:
    Command Description
    File chooser Single required file picker
    File chooser with other inputs File picker combined with text and boolean inputs
    File chooser (optional) Optional file picker that can be left empty
    File chooser (display content) Reads the selected file's text content and displays it in a markdown code block message box
  • Added the project to Aspire.slnx

How to test

  1. Run the Interactions playground:
    dotnet run --project playground/Interactions/Interactions.AppHost/Interactions.AppHost.csproj
    
  2. Open the dashboard URL shown in the console output
  3. Find the interactions resource and click its actions menu
  4. Try the File chooser and File chooser (display content) commands
  5. Verify:
    • The browse button opens the native file picker
    • The selected filename is shown in the text field
    • The file content is passed through and logged/displayed correctly
    • Required validation works (cannot submit without selecting a file)
    • The optional variant allows submitting without a file

Files changed (27 modified + 7 new)

Modified

  • Aspire.slnx — added Interactions playground project
  • src/Aspire.Hosting/IInteractionService.cs — added FileChooser enum member
  • src/Aspire.Hosting/InteractionService.cs — added validation case
  • src/Aspire.Hosting/api/Aspire.Hosting.cs — updated public API
  • src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto — added proto enum value
  • src/Aspire.Hosting/Dashboard/DashboardService.cs — added mapping
  • src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor — added FileChooser UI
  • src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs — added file handler
  • src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css — added styles
  • src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs — added FileDisplayName
  • src/Aspire.Dashboard/Resources/Dialogs.resx — added localization strings
  • src/Aspire.Dashboard/Resources/Dialogs.Designer.cs — added generated properties
  • src/Aspire.Dashboard/Resources/xlf/Dialogs.*.xlf — updated translation files (12 languages)
  • src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs — added CLI enum member
  • src/Aspire.Cli/Commands/PipelineCommandBase.cs — added CLI handling

New

  • playground/Interactions/Interactions.AppHost/ — new playground project (7 files)

This commit introduces a new FileChooser input type to the Aspire
Interaction Service, enabling users to select files via the native
OS/browser file picker dialog within interaction prompts.

## Changes across the full stack

### Aspire.Hosting (Server-side model)
- Added 'FileChooser' member to the 'InputType' enum in
  IInteractionService.cs, providing a new input type for file selection.
- Added validation handling for 'FileChooser' in InteractionService.cs
  RunValidationAsync method. File chooser values are validated for
  required/empty checks but have no additional format constraints.
- Updated the public API surface in Aspire.Hosting.cs to include the
  new enum member (FileChooser = 5).

### Proto/gRPC contract (dashboard_service.proto)
- Added 'INPUT_TYPE_FILE_CHOOSER = 6' to the InputType enum in the
  protobuf definition, extending the gRPC contract between the AppHost
  and Dashboard.

### Dashboard Service (Mapping layer)
- Added bidirectional mapping for FileChooser in both MapInputType
  methods in DashboardService.cs, converting between the Hosting and
  Proto InputType enums.

### Dashboard UI (Blazor frontend)
- Added a new 'FileChooser' case in InteractionsInputDialog.razor that
  renders a read-only FluentTextField (showing the selected filename)
  alongside a FluentInputFile component with a Browse button. The file
  is read into memory via InputFileMode.Stream and its text content is
  passed as the input Value.
- Added OnFileSelected handler in InteractionsInputDialog.razor.cs that
  reads the uploaded file stream into a string and sets both the Value
  (file content) and FileDisplayName (original filename).
- Added FileDisplayName property to InputViewModel.cs for displaying the
  original filename while Value holds the file content.
- Added CSS styles for the file-chooser-container layout in
  InteractionsInputDialog.razor.css.
- Added localization strings for the file chooser placeholder and browse
  button text in Dialogs.resx and Dialogs.Designer.cs.

### CLI (Aspire.Cli)
- Added 'FileChooser' to the CLI's own InputType enum in
  ExtensionBackchannelDataTypes.cs.
- Added FileChooser case in PipelineCommandBase.cs that prompts for a
  file path string in CLI mode.

### Playground sample (new project)
- Created a new 'Interactions' playground project dedicated to testing
  interaction service enhancements, with four sample commands:
  - File chooser: single required file picker
  - File chooser with other inputs: file picker combined with text and
    boolean inputs
  - File chooser (optional): optional file picker
  - File chooser (display content): reads and displays the selected
    file's text content in a message box
- Added the project to the solution file (Aspire.slnx).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 3, 2026 14:34
@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 14882

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 14882"

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new FileChooser input type to the Aspire Interaction Service, enabling file selection in Dashboard prompts and a CLI fallback path prompt.

Changes:

  • Added FileChooser to InputType across Hosting API, proto contract, Dashboard mapping, and CLI backchannel types.
  • Implemented Dashboard UI for file picking (Browse button + filename display) and a Blazor handler to read the selected file.
  • Added an Interactions playground AppHost project with sample commands exercising the new input type.

Reviewed changes

Copilot reviewed 33 out of 35 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
Aspire.slnx Adds the new Interactions playground project to the solution.
src/Aspire.Hosting/api/Aspire.Hosting.cs Updates public API enum with FileChooser.
src/Aspire.Hosting/IInteractionService.cs Adds FileChooser to the hosting model enum and docs.
src/Aspire.Hosting/InteractionService.cs Adds a validation switch-case for the new input type.
src/Aspire.Hosting/Dashboard/proto/dashboard_service.proto Extends gRPC enum with INPUT_TYPE_FILE_CHOOSER.
src/Aspire.Hosting/Dashboard/DashboardService.cs Adds bidirectional mapping for FileChooser.
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor Renders FileChooser UI (read-only filename + browse button).
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.cs Implements file selection handler that sets input values.
src/Aspire.Dashboard/Components/Dialogs/InteractionsInputDialog.razor.css Adds layout styles for the FileChooser UI.
src/Aspire.Dashboard/Model/Interaction/InputViewModel.cs Adds FileDisplayName for filename display.
src/Aspire.Dashboard/Resources/Dialogs.resx Adds localized strings for placeholder and browse label.
src/Aspire.Dashboard/Resources/xlf/Dialogs.*.xlf Adds new localization entries for multiple languages.
src/Aspire.Cli/Backchannel/ExtensionBackchannelDataTypes.cs Adds FileChooser to CLI backchannel enum.
src/Aspire.Cli/Commands/PipelineCommandBase.cs Adds CLI fallback handling for FileChooser prompts.
playground/Interactions/Interactions.AppHost/* Adds a new playground AppHost and sample interaction commands.
Files not reviewed (1)
  • src/Aspire.Dashboard/Resources/Dialogs.Designer.cs: Language not supported
Comments suppressed due to low confidence (1)

src/Aspire.Hosting/InteractionService.cs:1

  • This comment states FileChooser values are file paths, but the Dashboard UI handler sets Value to the file content. Either update the comment (and any assumptions in validation) to match the “content in Value” contract, or change the UI/model so Value is a path and content is provided separately.

Comment on lines +201 to +212
private async Task OnFileSelected(IEnumerable<FluentInputFileEventArgs> args, InputViewModel inputModel)
{
var file = args.FirstOrDefault();
if (file?.Stream != null)
{
using var reader = new StreamReader(file.Stream);
inputModel.Value = await reader.ReadToEndAsync();
inputModel.FileDisplayName = file.Name;
_editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel));
}
}

Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

Reading the entire uploaded file into a string with no explicit maximum size can enable memory exhaustion (and will also corrupt binary files / non-text encodings). Consider enforcing a maximum upload size, and represent the payload in a binary-safe form (e.g., bytes/base64) or explicitly constrain FileChooser to text files and document/validate that constraint.

Suggested change
private async Task OnFileSelected(IEnumerable<FluentInputFileEventArgs> args, InputViewModel inputModel)
{
var file = args.FirstOrDefault();
if (file?.Stream != null)
{
using var reader = new StreamReader(file.Stream);
inputModel.Value = await reader.ReadToEndAsync();
inputModel.FileDisplayName = file.Name;
_editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel));
}
}
// Maximum number of bytes to read from an uploaded file into memory.
// Files larger than this will be truncated to this limit when read.
private const long MaxUploadedFileBytes = 1024 * 1024; // 1 MB
private async Task OnFileSelected(IEnumerable<FluentInputFileEventArgs> args, InputViewModel inputModel)
{
var file = args.FirstOrDefault();
if (file?.Stream != null)
{
// Wrap the file stream to prevent reading more than MaxUploadedFileBytes into memory.
using var limitedStream = new LimitedStream(file.Stream, MaxUploadedFileBytes);
using var reader = new StreamReader(limitedStream);
inputModel.Value = await reader.ReadToEndAsync();
inputModel.FileDisplayName = file.Name;
_editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel));
}
}
private sealed class LimitedStream : Stream
{
private readonly Stream _innerStream;
private readonly long _maxBytes;
private long _bytesRead;
public LimitedStream(Stream innerStream, long maxBytes)
{
_innerStream = innerStream ?? throw new ArgumentNullException(nameof(innerStream));
_maxBytes = maxBytes >= 0 ? maxBytes : throw new ArgumentOutOfRangeException(nameof(maxBytes));
}
public override bool CanRead => _innerStream.CanRead;
public override bool CanSeek => _innerStream.CanSeek;
public override bool CanWrite => false;
public override long Length => _innerStream.Length;
public override long Position
{
get => _innerStream.Position;
set => _innerStream.Position = value;
}
public override void Flush() => _innerStream.Flush();
public override int Read(byte[] buffer, int offset, int count)
{
if (_bytesRead >= _maxBytes)
{
return 0;
}
var remaining = _maxBytes - _bytesRead;
if (count > remaining)
{
count = (int)remaining;
}
var read = _innerStream.Read(buffer, offset, count);
_bytesRead += read;
return read;
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
if (_bytesRead >= _maxBytes)
{
return 0;
}
var remaining = _maxBytes - _bytesRead;
if (count > remaining)
{
count = (int)remaining;
}
var read = await _innerStream.ReadAsync(buffer.AsMemory(offset, count), cancellationToken).ConfigureAwait(false);
_bytesRead += read;
return read;
}
public override long Seek(long offset, SeekOrigin origin) => _innerStream.Seek(offset, origin);
public override void SetLength(long value) => _innerStream.SetLength(value);
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 5c2f9f6. Added a LimitedStream wrapper that caps reads at 1MB (MaxUploadedFileBytes). The stream silently truncates beyond the limit. Also added both the array-based and Memory<byte>-based ReadAsync overrides to satisfy CA1844.

Comment on lines +209 to +210
_editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel));
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The UI binds the visible field to inputModel.FileDisplayName, but the edit context notification here appears to target a single field identifier (likely Value). This can prevent required/validation state from updating for FileDisplayName when a file is selected programmatically. Notify the correct field identifier(s) for FileDisplayName (and Value if needed), and consider clearing both properties + notifying when no file is selected to avoid stale values.

Suggested change
_editContext.NotifyFieldChanged(GetFieldIdentifier(inputModel));
}
_editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.Value)));
_editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.FileDisplayName)));
}
else
{
inputModel.Value = string.Empty;
inputModel.FileDisplayName = string.Empty;
_editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.Value)));
_editContext.NotifyFieldChanged(FieldIdentifier.Create(inputModel, nameof(inputModel.FileDisplayName)));
}

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 5c2f9f6. Now notifying both Value and FileDisplayName field identifiers via explicit FieldIdentifier construction. Also added an else branch that clears both properties and notifies when no file is selected, preventing stale values.

// Used to track secret text visibility state
public bool IsSecretTextVisible { get; set; }

// Used to display the original filename for FileChooser inputs while Value holds the temp file path.
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

This comment conflicts with the current Dashboard implementation, which assigns file contents to Value (not a temp file path). Update the comment to reflect the actual behavior, or adjust the implementation to match the documented “temp file path” model.

Suggested change
// Used to display the original filename for FileChooser inputs while Value holds the temp file path.
// Used to display the original filename for FileChooser inputs while Value holds the file contents.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 5c2f9f6. Updated the comment to say 'file contents' instead of 'temp file path', matching the actual stream-based implementation.

Comment on lines +38 to +43
logger.LogInformation("Selected file: {Value}", result.Data.Value);

_ = interactionService.PromptMessageBoxAsync(
"File selected",
$"You selected: {result.Data.Value}",
new MessageBoxInteractionOptions { Intent = MessageIntent.Success });
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

These samples log and display result.Data.Value, which (per the current Dashboard implementation) contains the full file contents. This can leak secrets (certs/configs) into logs/UI and can also produce very large log entries. Prefer logging/displaying only the filename (or size/preview with strict truncation), and avoid writing raw content unless the sample is explicitly about showing content (and then keep a tight size cap).

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Fixed in 5c2f9f6. All sample commands now log only the file size (character count) instead of raw content. The file-chooser and multi-input samples log ({Size} characters); the multi-input sample also distinguishes FileChooser inputs from other types when logging. The display-content sample still shows a truncated preview in a message box (for demonstration purposes) but no longer writes raw content to logs.

mcumming and others added 4 commits March 3, 2026 09:44
…tions, and sanitize logging

- Added a 1MB LimitedStream wrapper around file uploads to prevent
  memory exhaustion from large files. The stream caps reads at
  MaxUploadedFileBytes (1MB) and silently truncates beyond that.
  Added both array-based and memory-based ReadAsync overrides.

- Fixed field change notifications in OnFileSelected to notify both
  the Value and FileDisplayName field identifiers, ensuring validation
  state updates correctly in the edit context. Also handles the empty
  selection case by clearing both properties.

- Fixed stale comment on FileDisplayName property to correctly state
  that Value holds file contents (not a temp file path).

- Updated all playground sample commands to avoid logging raw file
  contents, which could leak secrets (certs, configs) into logs.
  Commands now log only the file size in characters. The display-content
  sample still shows a truncated preview in a message box but no longer
  writes the raw content to logs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Added a MaxFileSize property (long?) to InteractionInput that allows
callers to override the default 1MB upload size limit per input. The
property follows the same validation pattern as MaxLength, rejecting
values less than or equal to zero.

- Added MaxFileSize property to InteractionInput in IInteractionService.cs
- Added max_file_size field (int64, field 16) to the proto definition
- Added mapping in DashboardService.cs to pass the value to the proto
- Updated the Dashboard dialog to read MaxFileSize from the input and
  fall back to DefaultMaxUploadedFileBytes (1MB) when not specified
- Updated the display-content playground sample to demonstrate setting
  MaxFileSize = 5MB
- Updated public API surface

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The image should not have been committed to the repo. It was intended
only for the PR description.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add comprehensive unit tests for the new FileChooser input type across
the test infrastructure:

InteractionServiceTests (Aspire.Hosting.Tests):
- PromptInputsAsync_FileChooserWithValue_PassesValidation: Verifies that
  a FileChooser input with a non-empty value passes validation when the
  interaction is completed, confirming no format-specific validation
  constraints exist for file content.
- PromptInputsAsync_FileChooserRequiredEmpty_ReturnErrors: Verifies that
  a required FileChooser input with an empty value produces a 'Value is
  required.' validation error and prevents interaction completion.
- PromptInputsAsync_FileChooserOptionalEmpty_PassesValidation: Verifies
  that an optional FileChooser input with an empty value passes
  validation successfully.
- InteractionInput_MaxFileSize_RejectsInvalidValues: Verifies that
  setting MaxFileSize to 0 or negative values throws
  ArgumentOutOfRangeException, consistent with MaxLength validation.
- InteractionInput_MaxFileSize_AcceptsValidValues: Verifies that valid
  positive MaxFileSize values are accepted and stored correctly.
- InteractionInput_MaxFileSize_DefaultsToNull: Verifies that MaxFileSize
  defaults to null when not explicitly set.

InputViewModelTests (Aspire.Dashboard.Components.Tests):
- InputViewModel_FileChooser_DefaultsToEmptyValue: Verifies that a
  FileChooser InputViewModel initializes with empty Value and null
  FileDisplayName.
- InputViewModel_FileChooser_FileDisplayNameIsIndependentOfValue:
  Verifies that FileDisplayName and Value can be set independently,
  as FileDisplayName holds the original filename while Value holds
  the file content.
- InputViewModel_FileChooser_SetInputResetsFileDisplayName: Verifies
  the behavior of SetInput with FileChooser inputs and FileDisplayName
  retention.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@davidfowl
Copy link
Member

@JamesNK

@JamesNK
Copy link
Member

JamesNK commented Mar 4, 2026

@mcumming I implemented basically exactly what you have in your PR. We ended up not merging it because there are file size limitations when using the existing gRPC call. For example, a 50 megabyte file would exceed size limits and fail.

My old PR: #10233

I think to remove the file size limit there would need to be a new API for uploading the file, it is written to a temporary disk location, the interactioninput result in the app host would need to know the location of the file (probably there would be a shared ID), it has a easy way to access the file, and the file is cleaned up when it's no longer used (this could be tricky, I don't remember if there is a "lifetime" on interaction input results to know where it's safe to do clean up)

@mcumming
Copy link
Author

mcumming commented Mar 4, 2026

@mcumming I implemented basically exactly what you have in your PR. We ended up not merging it because there are file size limitations when using the existing gRPC call. For example, a 50 megabyte file would exceed size limits and fail.

My old PR: #10233

I think to remove the file size limit there would need to be a new API for uploading the file, it is written to a temporary disk location, the interactioninput result in the app host would need to know the location of the file (probably there would be a shared ID), it has a easy way to access the file, and the file is cleaned up when it's no longer used (this could be tricky, I don't remember if there is a "lifetime" on interaction input results to know where it's safe to do clean up)

That's really funny, because that was what CoPilot implemented to begin with (before the first commit), then I directed it to upload the file.

@mcumming
Copy link
Author

mcumming commented Mar 4, 2026

Plan: Adjust FileChooser to Stream-Based API with Temp File Transport

Problem Statement

JamesNK (comment) pointed out that the current FileChooser implementation (sending file content as a string Value via gRPC) hits gRPC's ~4MB message size limit on large files — the same issue that blocked his earlier PR #10233.

davidfowl's feedback (comment):

"I'd much prefer this to be using streams... We could fake it on the consumer side by making the API stream based and can change the underlying protocol in 9.5 to actually support it."

And (comment):

"Local apphost, the files are all on the same computer so there's no need to send it over grpc. The file path is enough as the client and server are within the same trust boundary."

Component Architecture

Current Data Flow (Before Changes)

sequenceDiagram
    participant Browser as Browser
    participant Dashboard as Dashboard Server<br/>(Blazor Server)
    participant AppHost as AppHost<br/>(InteractionService)
    participant Consumer as Extension Code<br/>(Consumer)

    Note over AppHost,Consumer: Extension registers a FileChooser interaction
    Consumer->>AppHost: PromptInputAsync(FileChooser)
    AppHost->>Dashboard: gRPC Stream: WatchInteractionsResponseUpdate<br/>(interaction prompt with FileChooser input)
    Dashboard->>Browser: Render FluentInputFile + Browse button

    Browser->>Dashboard: User selects file via native picker
    Note over Dashboard: ⚠️ Reads ENTIRE file into memory<br/>stores content as string in Value
    Dashboard->>AppHost: gRPC Stream: WatchInteractionsRequestUpdate<br/>value = file content string (⚠️ hits 4MB limit)
    AppHost->>Consumer: InteractionResult.Data.Value = file content string
    Note over Consumer: Consumer gets raw string content<br/>No stream access, no filename metadata
Loading
sequenceDiagram
    participant CLI as CLI Terminal
    participant Backchannel as AppHost RPC Target<br/>(StreamJsonRpc)
    participant AppHost as AppHost<br/>(InteractionService)
    participant Consumer as Extension Code<br/>(Consumer)

    Consumer->>AppHost: PromptInputAsync(FileChooser)
    AppHost->>Backchannel: Prompt activity via backchannel
    Backchannel->>CLI: Display prompt for file path
    CLI->>Backchannel: User types file path string
    Backchannel->>AppHost: CompletePromptResponseAsync<br/>(PublishingPromptInputAnswer.Value = typed path)
    AppHost->>Consumer: InteractionResult.Data.Value = typed path string
Loading

Proposed Data Flow (After Changes)

sequenceDiagram
    participant Browser as Browser
    participant Dashboard as Dashboard Server<br/>(Blazor Server)
    participant TempFS as Temp File System<br/>(aspire-uploads/)
    participant AppHost as AppHost<br/>(InteractionService)
    participant FSService as FileSystemService<br/>(Cleanup Tracking)
    participant Consumer as Extension Code<br/>(Consumer)

    Note over AppHost,Consumer: Extension registers a FileChooser interaction
    Consumer->>AppHost: PromptInputAsync(FileChooser)
    AppHost->>Dashboard: gRPC Stream: WatchInteractionsResponseUpdate<br/>(interaction prompt with FileChooser input)
    Dashboard->>Browser: Render FluentInputFile + Browse button

    Browser->>Dashboard: User selects file via native picker
    Dashboard->>TempFS: Save file to temp directory<br/>(no size limit)
    Note over TempFS: File saved as<br/>aspire-uploads/{guid}
    Dashboard->>AppHost: gRPC Stream: WatchInteractionsRequestUpdate<br/>value = temp file path (small string ✅)<br/>file_name = "original.csv"<br/>file_size = 52428800

    AppHost->>FSService: Register temp file for cleanup tracking
    AppHost->>Consumer: InteractionResult with<br/>Value = file path, FileName = "original.csv"

    Consumer->>Consumer: await input.OpenFileStreamAsync()
    Note over Consumer: Extension method opens FileStream<br/>from temp path (ValueTask<Stream?>)
    Consumer->>Consumer: Read stream, process file

    Note over FSService: On AppHost shutdown:<br/>FileSystemService.Dispose()<br/>deletes tracked temp files
Loading
sequenceDiagram
    participant CLI as CLI Terminal
    participant Backchannel as AppHost RPC Target<br/>(StreamJsonRpc)
    participant AppHost as AppHost<br/>(InteractionService)
    participant Consumer as Extension Code<br/>(Consumer)

    Consumer->>AppHost: PromptInputAsync(FileChooser)
    AppHost->>Backchannel: Prompt activity via backchannel
    Backchannel->>CLI: Display prompt for file path
    CLI->>Backchannel: User types: /path/to/data.csv
    Note over Backchannel: Set Value = /path/to/data.csv<br/>Set FileName = data.csv<br/>(no temp file needed)
    Backchannel->>AppHost: CompletePromptResponseAsync<br/>(Value = path, FileName = data.csv)
    AppHost->>Consumer: InteractionResult with<br/>Value = /path/to/data.csv, FileName = "data.csv"

    Consumer->>Consumer: await input.OpenFileStreamAsync()
    Note over Consumer: Extension method opens FileStream<br/>directly from user's file path
Loading

Component Interaction Map

graph TB
    subgraph BrowserLayer["Browser (User's Machine)"]
        UI["FluentInputFile<br/>+ Browse Button"]
    end

    subgraph DashboardProcess["Dashboard Server Process"]
        Razor["InteractionsInputDialog.razor.cs<br/>(saves file to temp dir)"]
        VM["InputViewModel<br/>(Value=path, FileDisplayName, FileSize)"]
        DS["DashboardService.cs<br/>(gRPC endpoint)"]
    end

    subgraph AppHostProcess["AppHost Process"]
        DSD["DashboardServiceData<br/>(maps proto → InteractionInput)"]
        IS["InteractionService<br/>(interaction state machine)"]
        FSS["FileSystemService<br/>(temp file tracking + cleanup)"]
        RPC["AppHostRpcTarget<br/>(StreamJsonRpc endpoint)"]
        PAR["PipelineActivityReporter<br/>(CLI answer processing)"]
    end

    subgraph ConsumerCode["Extension / Consumer Code"]
        EXT["Extension method:<br/>OpenFileStreamAsync()<br/>→ ValueTask&lt;Stream?&gt;"]
        DEV["Developer's code<br/>(reads stream)"]
    end

    subgraph CLIProcess["CLI Process"]
        CMD["PipelineCommandBase<br/>(prompts for file path)"]
        BC["IAppHostCliBackchannel<br/>(StreamJsonRpc client)"]
    end

    subgraph TempStorage["Local File System"]
        TMP["aspire-uploads/<br/>{guid} temp files"]
        USR["User's actual file<br/>(CLI scenario)"]
    end

    UI -->|"SignalR"| Razor
    Razor -->|"saves file"| TMP
    Razor --> VM
    VM -->|"Value=path"| DS

    DS -->|"gRPC bidirectional stream<br/>WatchInteractions"| DSD
    DSD -->|"maps file_name, value"| IS
    IS -->|"registers temp file"| FSS
    IS -->|"InteractionResult"| DEV
    DEV -->|"OpenFileStreamAsync()"| EXT
    EXT -->|"opens FileStream from"| TMP

    CMD -->|"user types path"| BC
    BC -->|"StreamJsonRpc"| RPC
    RPC --> PAR
    PAR -->|"ProcessInputs"| IS

    EXT -.->|"CLI scenario:<br/>opens from"| USR

    style TMP fill:#fff3cd,stroke:#ffc107
    style USR fill:#fff3cd,stroke:#ffc107
    style FSS fill:#d4edda,stroke:#28a745
    style EXT fill:#cce5ff,stroke:#0066cc
Loading

Architectural Design Decisions

Decision 1: Stream-based consumer API vs. string content in Value

Chosen: Stream-based API via async extension method

Option Description Outcome
A. File content in Value (original PR) Read file into memory, send as string via gRPC value field ❌ Rejected — hits gRPC's ~4MB message size limit; buffers entire file in memory; only works for text files. This is what blocked JamesNK's PR #10233.
B. byte[] property on InteractionInput Store file content as byte[] instead of string ❌ Rejected by davidfowl: "The user should get a stream, not byte[].." Still buffers entire file in memory.
C. OpenFileStream() instance method on InteractionInput Add a synchronous Stream-returning method directly on InteractionInput ❌ Rejected — inconsistent with Aspire's API patterns. InteractionInput is a plain data container (all values are strings, no IDisposable, no resource methods). Adding a Stream-returning method would break this pattern. No other interaction input type returns a resource.
D. New PromptFileAsync() on IInteractionService Separate method returning InteractionResult<InteractionFileResult> with a Stream ❌ Rejected — creates a parallel API path for files vs. other inputs. Doesn't work for mixed-input dialogs (file + text + boolean in one prompt).
✅ E. Async extension method OpenFileStreamAsync() Extension method on InteractionInput returning ValueTask<Stream?>. Value holds file path, FileName holds original name. ✅ Chosen — keeps InteractionInput a clean data container. Follows Aspire's ValueTask<T> async pattern (GetConnectionStringAsync, GetValueAsync). "Fakes it" per davidfowl: today opens a local FileStream, tomorrow can be swapped to actual gRPC streaming without changing consumer code.

Decision 2: Transport mechanism (Dashboard → AppHost)

Chosen: Temp file on disk + file path over gRPC

Option Description Outcome
A. Content in gRPC message (original PR) Send file bytes in the existing value field ❌ Rejected — gRPC has a ~4MB default max message size. Would need MaxReceiveMessageSize increase, and still buffers everything in memory on both sides.
B. gRPC client streaming New streaming RPC to upload file in chunks ❌ Rejected for now — JamesNK outlined the complexity: new streaming API, temp disk storage, shared file IDs, association with interaction inputs, cleanup. Significant protocol change. davidfowl suggested this can come later: "can change the underlying protocol in 9.5 to actually support it."
✅ C. Temp file + file path Dashboard saves file to temp directory, sends only the path + metadata over gRPC ✅ Chosen — per davidfowl: "Local apphost, the files are all on the same computer so there's no need to send it over grpc. The file path is enough." No size limits. Small gRPC messages. Dashboard (Blazor Server) and AppHost are on the same machine in local dev.

Decision 3: Temp file cleanup strategy

Chosen: FileSystemService tracking + startup sweep

Option Description Outcome
A. FileOptions.DeleteOnClose OS auto-deletes file when last handle closes ❌ Rejected — platform-specific behavior. On Windows, works via FILE_FLAG_DELETE_ON_CLOSE. On Linux, requires separate File.Delete after open (unlinks inode). Fragile and prevents re-opening the stream.
B. Timer-based grace period Delete temp files after N minutes ❌ Rejected — arbitrary timing; complex to implement; risks deleting before consumer reads or keeping files too long.
C. IDisposable on InteractionInput Make InteractionInput disposable, delete temp file on dispose ❌ Rejected — no other interaction input type is disposable. Would be a breaking pattern change for a data container.
✅ D. FileSystemService tracking + startup sweep Register temp files with Aspire's existing FileSystemService infrastructure. Deleted on AppHost shutdown. Stale files from crashed sessions cleaned on next startup. ✅ Chosen — reuses proven infrastructure. FileSystemService already handles temp file tracking, graceful error handling, and ASPIRE_PRESERVE_TEMP_FILES debug flag. No custom lifecycle management needed.

Decision 4: Where FileName lives

Chosen: String property on InteractionInput

Option Description Outcome
A. Pack into Value alongside path Encode as `path filename` in Value
B. Separate InteractionFileResult wrapper New type wrapping FileName + Stream ❌ Rejected — adds type complexity; FileName is just a string, consistent with existing string properties on InteractionInput.
✅ C. FileName string property on InteractionInput public string? FileName { get; internal set; } ✅ Chosen — consistent with existing string properties (Name, Label, Value, Placeholder). Populated by transport layer (Dashboard sets from uploaded filename, CLI sets from Path.GetFileName).

Decision 5: CLI file handling

Chosen: Pass user's file path directly (no temp file)

Option Description Outcome
A. Copy to temp file for consistency CLI copies user file to temp dir, sends temp path ❌ Rejected — unnecessary I/O. CLI and AppHost are on the same machine; the user's file is directly accessible.
✅ B. Send user's file path directly CLI sets Value = user's provided path, FileName = Path.GetFileName(path) ✅ Chosen — simplest approach. No copying. Consumer opens the user's actual file via OpenFileStreamAsync(). CLI temp files are not registered for cleanup (they're the user's files, not ours to delete).

Proposed Approach

Two changes layered together:

  1. Stream-based consumer API via extension method — An async extension method on InteractionInput gives consumers stream access while keeping InteractionInput itself a simple data container (consistent with existing Aspire patterns where all input values are strings).
  2. Temp file transport — Dashboard saves the uploaded file to a temp directory on disk and sends only the file path + metadata over gRPC. This eliminates the 4MB message size limit entirely. Since Dashboard and AppHost run on the same machine (local dev), the AppHost can read the temp file directly.

API Design Rationale

Aspire's interaction API has a consistent pattern: all input values are string? Value (Boolean → "true", Number → "42", etc.) and InteractionInput is a plain data container with no IDisposable or resource-returning methods. Aspire's public APIs also avoid exposing Stream directly — async ValueTask<T> methods are preferred (like GetConnectionStringAsync, GetValueAsync).

To stay consistent while satisfying davidfowl's stream requirement, we:

  • Keep InteractionInput.Value as the file path (string — consistent)
  • Add InteractionInput.FileName as a string property (consistent)
  • Add an async extension method OpenFileStreamAsync() returning ValueTask<Stream?> (follows ValueTask<T> convention, keeps InteractionInput clean, future-proofs for gRPC streaming)

Architecture

Browser → [SignalR] → Dashboard Server (saves to temp file)
   → [gRPC: sends file_path + file_name + file_size] → AppHost
   → Consumer calls OpenFileStreamAsync() → FileStream from temp path

For CLI:

User provides file path → CLI sends file_path + file_name + file_size → AppHost
   → Consumer calls OpenFileStreamAsync() → FileStream from user's file directly

Migration Notes

  • Value for FileChooser now holds a file path instead of file content. This is a new, unshipped feature so there's no backward compat concern.
  • The MaxFileSize property on InteractionInput still works — the Dashboard enforces it when saving to the temp file.
  • No gRPC message size limit concerns since only the path (small string) is sent via value.
  • The stream-based async extension method is forward-compatible — when actual gRPC streaming is added later, only the extension method implementation changes. Consumer code stays the same.
  • FileName is a new string property on InteractionInput — consistent with the existing all-strings pattern.

Open Questions

  1. Temp directory location: Use Path.GetTempPath()/aspire-uploads/ or integrate with FileSystemService.TempDirectory directly?
  2. Max file size default: Keep 1MB default or increase now that size isn't limited by gRPC? (Disk is cheap, but we should still have a reasonable default.)
  3. Remote dashboard scenario: If Dashboard runs remotely, temp files won't work. Is this a concern for the initial implementation? (Likely not — local dev is the primary scenario.)
  4. Binary file support: OpenFileStreamAsync() returns raw bytes, naturally supporting both text and binary files. The previous string-based approach was text-only. This is an improvement.

@mcumming
Copy link
Author

mcumming commented Mar 4, 2026

@davidfowl RE: #10233 (comment), was there a second scenario? 1. reads like a real scenario, while 2. reads like a requirement.

@mcumming
Copy link
Author

mcumming commented Mar 6, 2026

@mitchdenny , @JamesNK gentle ping?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants